mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(channels): thread runtime config through sends
This commit is contained in:
@@ -109,15 +109,17 @@ export async function handleDiscordMessagingAction(
|
||||
}),
|
||||
);
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const cfgOptions = cfg ? { cfg } : {};
|
||||
const reactionRuntimeOptions = cfg
|
||||
if (!cfg) {
|
||||
throw new Error("Discord messaging actions require a resolved runtime config.");
|
||||
}
|
||||
const cfgOptions = { cfg };
|
||||
const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg);
|
||||
const reactionRuntimeOptions = resolvedReactionAccountId
|
||||
? createDiscordRuntimeAccountContext({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultDiscordAccountId(cfg),
|
||||
accountId: resolvedReactionAccountId,
|
||||
})
|
||||
: accountId
|
||||
? { accountId }
|
||||
: undefined;
|
||||
: cfgOptions;
|
||||
const withReactionRuntimeOptions = (extra?: Record<string, unknown>) => ({
|
||||
...(reactionRuntimeOptions ?? cfgOptions),
|
||||
...extra,
|
||||
|
||||
@@ -82,6 +82,20 @@ const {
|
||||
} = discordSendMocks;
|
||||
|
||||
const enableAllActions = () => true;
|
||||
const DISCORD_TEST_CFG = {} as OpenClawConfig;
|
||||
|
||||
function handleMessagingAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
|
||||
options?: {
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
},
|
||||
cfg: OpenClawConfig = DISCORD_TEST_CFG,
|
||||
) {
|
||||
return handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
|
||||
}
|
||||
|
||||
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
||||
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
|
||||
@@ -112,7 +126,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
messageId: "M1",
|
||||
emoji: "✅",
|
||||
},
|
||||
expectedOptions: undefined,
|
||||
expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "default" },
|
||||
},
|
||||
{
|
||||
name: "with accountId",
|
||||
@@ -122,19 +136,21 @@ describe("handleDiscordMessagingAction", () => {
|
||||
emoji: "✅",
|
||||
accountId: "ops",
|
||||
},
|
||||
expectedOptions: { accountId: "ops" },
|
||||
expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "ops" },
|
||||
},
|
||||
])("adds reactions $name", async ({ params, expectedOptions }) => {
|
||||
await handleDiscordMessagingAction("react", params, enableAllActions);
|
||||
await handleMessagingAction("react", params, enableAllActions);
|
||||
if (expectedOptions) {
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions);
|
||||
return;
|
||||
}
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -164,7 +180,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -173,11 +189,14 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {});
|
||||
expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes reactions when remove flag set", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -187,12 +206,15 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||
expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects removes without emoji", async () => {
|
||||
await expect(
|
||||
handleDiscordMessagingAction(
|
||||
handleMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -207,7 +229,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
|
||||
it("respects reaction gating", async () => {
|
||||
await expect(
|
||||
handleDiscordMessagingAction(
|
||||
handleMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -220,7 +242,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
});
|
||||
|
||||
it("parses string booleans for poll options", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"poll",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -249,7 +271,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
|
||||
] as never);
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
const result = await handleMessagingAction(
|
||||
"readMessages",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
@@ -271,13 +293,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
"readMessages",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, {}, cfg);
|
||||
expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg });
|
||||
});
|
||||
|
||||
@@ -287,7 +303,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
timestamp: "2026-01-15T11:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
const result = await handleMessagingAction(
|
||||
"fetchMessage",
|
||||
{ guildId: "G1", channelId: "C1", messageId: "M1" },
|
||||
enableAllActions,
|
||||
@@ -307,7 +323,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"fetchMessage",
|
||||
{ guildId: "G1", channelId: "C1", messageId: "M1" },
|
||||
enableAllActions,
|
||||
@@ -320,11 +336,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
it("adds normalized timestamps to listPins payloads", async () => {
|
||||
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
"listPins",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
);
|
||||
const result = await handleMessagingAction("listPins", { channelId: "C1" }, enableAllActions);
|
||||
const payload = result.details as {
|
||||
pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
|
||||
};
|
||||
@@ -340,7 +352,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
|
||||
});
|
||||
|
||||
const result = await handleDiscordMessagingAction(
|
||||
const result = await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ guildId: "G1", content: "hi" },
|
||||
enableAllActions,
|
||||
@@ -360,7 +372,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
sendVoiceMessageDiscord.mockClear();
|
||||
sendMessageDiscord.mockClear();
|
||||
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"sendMessage",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -372,6 +384,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
);
|
||||
|
||||
expect(sendVoiceMessageDiscord).toHaveBeenCalledWith("channel:123", "/tmp/voice.mp3", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
replyTo: undefined,
|
||||
silent: true,
|
||||
});
|
||||
@@ -380,7 +393,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
|
||||
it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => {
|
||||
sendMessageDiscord.mockClear();
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"sendMessage",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -404,7 +417,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
sendMessageDiscord.mockClear();
|
||||
sendDiscordComponentMessage.mockClear();
|
||||
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"sendMessage",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -429,7 +442,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
|
||||
it("forwards the optional filename into sendMessageDiscord", async () => {
|
||||
sendMessageDiscord.mockClear();
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"sendMessage",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -451,7 +464,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
|
||||
it("rejects voice messages that include content", async () => {
|
||||
await expect(
|
||||
handleDiscordMessagingAction(
|
||||
handleMessagingAction(
|
||||
"sendMessage",
|
||||
{
|
||||
to: "channel:123",
|
||||
@@ -466,7 +479,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
|
||||
it("forwards optional thread content", async () => {
|
||||
createThreadDiscord.mockClear();
|
||||
await handleDiscordMessagingAction(
|
||||
await handleMessagingAction(
|
||||
"threadCreate",
|
||||
{
|
||||
channelId: "C1",
|
||||
@@ -484,7 +497,7 @@ describe("handleDiscordMessagingAction", () => {
|
||||
content: "Initial forum post body",
|
||||
appliedTags: undefined,
|
||||
},
|
||||
{},
|
||||
{ cfg: DISCORD_TEST_CFG },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -778,8 +778,13 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
idLabel: "discordUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i),
|
||||
notify: async ({ id, message }) => {
|
||||
await (await loadDiscordSendModule()).sendMessageDiscord(`user:${id}`, message);
|
||||
notify: async ({ cfg, id, message, accountId }) => {
|
||||
await (
|
||||
await loadDiscordSendModule()
|
||||
).sendMessageDiscord(`user:${id}`, message, {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RequestClient } from "@buape/carbon";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -16,7 +16,7 @@ import type { DiscordRuntimeAccountContext } from "./send.types.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
export type DiscordClientOpts = {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
cfg?: OpenClawConfig;
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
@@ -25,7 +25,7 @@ export type DiscordClientOpts = {
|
||||
};
|
||||
|
||||
export function createDiscordRuntimeAccountContext(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): DiscordRuntimeAccountContext {
|
||||
return {
|
||||
@@ -36,10 +36,16 @@ export function createDiscordRuntimeAccountContext(params: {
|
||||
|
||||
export function resolveDiscordClientAccountContext(
|
||||
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
|
||||
cfg?: ReturnType<typeof loadConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
runtime?: Pick<RuntimeEnv, "error">,
|
||||
) {
|
||||
const resolvedCfg = opts.cfg ?? cfg ?? loadConfig();
|
||||
const config = opts.cfg ?? cfg;
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
"Discord client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const resolvedCfg = requireRuntimeConfig(config, "Discord client");
|
||||
const account = resolveAccountWithoutToken({
|
||||
cfg: resolvedCfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -63,7 +69,7 @@ function resolveToken(params: { accountId: string; fallbackToken?: string }) {
|
||||
|
||||
export function resolveDiscordProxyFetch(
|
||||
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
|
||||
cfg?: ReturnType<typeof loadConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
runtime?: Pick<RuntimeEnv, "error">,
|
||||
): typeof fetch | undefined {
|
||||
return resolveDiscordClientAccountContext(opts, cfg, runtime).proxyFetch;
|
||||
@@ -72,7 +78,7 @@ export function resolveDiscordProxyFetch(
|
||||
function resolveRest(
|
||||
token: string,
|
||||
account: ResolvedDiscordAccount,
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
cfg: OpenClawConfig,
|
||||
rest?: RequestClient,
|
||||
proxyFetch?: typeof fetch,
|
||||
) {
|
||||
@@ -87,7 +93,7 @@ function resolveRest(
|
||||
}
|
||||
|
||||
function resolveAccountWithoutToken(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedDiscordAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
@@ -104,10 +110,7 @@ function resolveAccountWithoutToken(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordRestClient(
|
||||
opts: DiscordClientOpts,
|
||||
cfg?: ReturnType<typeof loadConfig>,
|
||||
) {
|
||||
export function createDiscordRestClient(opts: DiscordClientOpts, cfg?: OpenClawConfig) {
|
||||
const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token");
|
||||
const proxyContext = resolveDiscordClientAccountContext(opts, cfg);
|
||||
const resolvedCfg = proxyContext.cfg;
|
||||
@@ -126,7 +129,7 @@ export function createDiscordRestClient(
|
||||
|
||||
export function createDiscordClient(
|
||||
opts: DiscordClientOpts,
|
||||
cfg?: ReturnType<typeof loadConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
): { token: string; rest: RequestClient; request: RetryRunner } {
|
||||
const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg);
|
||||
const request = createDiscordRetryRunner({
|
||||
|
||||
@@ -272,6 +272,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
},
|
||||
{
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
||||
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
|
||||
import { isDangerousNameMatchingEnabled, loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-binding-runtime";
|
||||
import { enqueueSystemEvent, recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
@@ -536,6 +536,7 @@ export async function preflightDiscordMessage(
|
||||
code,
|
||||
}),
|
||||
{
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
rest: params.client.rest,
|
||||
accountId: params.accountId,
|
||||
@@ -602,15 +603,14 @@ export async function preflightDiscordMessage(
|
||||
earlyThreadParentType = parentInfo.type;
|
||||
}
|
||||
|
||||
// Use the active runtime snapshot for bindings lookup; routing inputs are
|
||||
// still payload-derived, but this path should not reparse config from disk.
|
||||
// Routing inputs are payload-derived, but config must come from the boundary
|
||||
// snapshot already threaded into the monitor path.
|
||||
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
|
||||
? params.data.rawMember.roles
|
||||
: [];
|
||||
const freshCfg = loadConfig();
|
||||
const conversationRuntime = await loadConversationRuntime();
|
||||
const route = resolveDiscordConversationRoute({
|
||||
cfg: freshCfg,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
guildId: params.data.guild_id ?? undefined,
|
||||
memberRoleIds,
|
||||
@@ -639,7 +639,7 @@ export async function preflightDiscordMessage(
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? conversationRuntime.resolveConfiguredBindingRoute({
|
||||
cfg: freshCfg,
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
@@ -1047,7 +1047,7 @@ export async function preflightDiscordMessage(
|
||||
}
|
||||
if (configuredBinding) {
|
||||
const ensured = await conversationRuntime.ensureConfiguredBindingRouteReady({
|
||||
cfg: freshCfg,
|
||||
cfg: params.cfg,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
|
||||
@@ -842,7 +842,7 @@ describe("discord component interactions", () => {
|
||||
"user:123456789",
|
||||
"msg-1",
|
||||
{ text: expect.any(String) },
|
||||
{ accountId: "default" },
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ export function isDiscordThreadGoneError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export async function maybeSendBindingMessage(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
record: ThreadBindingRecord;
|
||||
text: string;
|
||||
preferWebhook?: boolean;
|
||||
|
||||
@@ -259,6 +259,7 @@ describe("thread binding lifecycle", () => {
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: {} as OpenClawConfig,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
@@ -298,6 +299,7 @@ describe("thread binding lifecycle", () => {
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: {} as OpenClawConfig,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 0,
|
||||
|
||||
@@ -491,7 +491,7 @@ export function createThreadBindingManager(
|
||||
}
|
||||
|
||||
const introText = bindParams.introText?.trim();
|
||||
if (introText) {
|
||||
if (introText && cfg) {
|
||||
void maybeSendBindingMessage({ cfg, record, text: introText });
|
||||
}
|
||||
return record;
|
||||
@@ -532,12 +532,14 @@ export function createThreadBindingManager(
|
||||
});
|
||||
// Use bot send path for farewell messages so unbound threads don't process
|
||||
// webhook echoes as fresh inbound turns when allowBots is enabled.
|
||||
void maybeSendBindingMessage({
|
||||
cfg,
|
||||
record: removed,
|
||||
text: farewell,
|
||||
preferWebhook: false,
|
||||
});
|
||||
if (cfg) {
|
||||
void maybeSendBindingMessage({
|
||||
cfg,
|
||||
record: removed,
|
||||
text: farewell,
|
||||
preferWebhook: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ function resolveDiscordWebhookIdentity(params: {
|
||||
}
|
||||
|
||||
async function maybeSendDiscordWebhookText(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
text: string;
|
||||
threadId?: string | number | null;
|
||||
accountId?: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { parseAndResolveDiscordTarget } from "./target-resolver.js";
|
||||
|
||||
@@ -17,7 +17,12 @@ export async function parseAndResolveRecipient(
|
||||
accountId?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<DiscordRecipient> {
|
||||
const resolvedCfg = cfg ?? loadConfig();
|
||||
if (!cfg) {
|
||||
throw new Error(
|
||||
"Discord recipient resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const resolvedCfg = requireRuntimeConfig(cfg, "Discord recipient resolution");
|
||||
const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId });
|
||||
const trimmed = raw.trim();
|
||||
const parseOptions = {
|
||||
|
||||
@@ -4,6 +4,17 @@ import { makeDiscordRest } from "./send.test-harness.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
|
||||
|
||||
const DISCORD_TEST_CFG = {
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { dmScope: "main" },
|
||||
} as const;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
@@ -75,6 +86,7 @@ describe("sendDiscordComponentMessage", () => {
|
||||
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
sessionKey: "agent:main:discord:channel:dm-1",
|
||||
@@ -103,6 +115,7 @@ describe("sendDiscordComponentMessage", () => {
|
||||
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
sessionKey: "agent:main:discord:channel:chan-1",
|
||||
@@ -153,6 +166,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => {
|
||||
"channel:chan-1",
|
||||
{ blocks: [{ type: "text", text: "report" }] },
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
mediaReadFile: readFileMock,
|
||||
@@ -189,6 +203,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
@@ -219,6 +234,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => {
|
||||
blocks: [{ type: "file", file: "attachment://report.pdf", spoiler: true }],
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
@@ -246,6 +262,7 @@ describe("sendDiscordComponentMessage classic message downgrade", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RequestClient,
|
||||
} from "@buape/carbon";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
@@ -141,7 +141,7 @@ function collapseClassicComponentText(spec: DiscordComponentMessageSpec): string
|
||||
}
|
||||
|
||||
type DiscordComponentSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
@@ -244,7 +244,7 @@ async function buildDiscordComponentPayload(params: {
|
||||
export async function sendDiscordComponentMessage(
|
||||
to: string,
|
||||
spec: DiscordComponentMessageSpec,
|
||||
opts: DiscordComponentSendOpts = {},
|
||||
opts: DiscordComponentSendOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const classicDecision = getClassicDiscordMessageDecision(spec);
|
||||
if (opts.mediaUrl && classicDecision.mode === "classic") {
|
||||
@@ -263,7 +263,7 @@ export async function sendDiscordComponentMessage(
|
||||
});
|
||||
}
|
||||
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord component send");
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||
@@ -293,6 +293,7 @@ export async function sendDiscordComponentMessage(
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
@@ -320,9 +321,9 @@ export async function editDiscordComponentMessage(
|
||||
to: string,
|
||||
messageId: string,
|
||||
spec: DiscordComponentMessageSpec,
|
||||
opts: DiscordComponentSendOpts = {},
|
||||
opts: DiscordComponentSendOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord component edit");
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||
@@ -345,6 +346,7 @@ export async function editDiscordComponentMessage(
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
|
||||
@@ -23,6 +23,20 @@ let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord;
|
||||
let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord;
|
||||
let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord;
|
||||
|
||||
const DISCORD_TEST_CFG = {
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function discordClientOpts(rest: ReturnType<typeof makeDiscordRest>["rest"]) {
|
||||
return { cfg: DISCORD_TEST_CFG, rest, token: "t" };
|
||||
}
|
||||
|
||||
function createCompatRateLimitError(
|
||||
response: Response,
|
||||
body: { message: string; retry_after: number; global: boolean },
|
||||
@@ -75,7 +89,11 @@ describe("sendMessageDiscord", () => {
|
||||
it("creates a thread", async () => {
|
||||
const { rest, getMock, postMock } = makeDiscordRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" });
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1", "m1"),
|
||||
@@ -87,7 +105,7 @@ describe("sendMessageDiscord", () => {
|
||||
const { rest, getMock, postMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValue({ type: ChannelType.GuildForum });
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
|
||||
await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1"),
|
||||
@@ -107,7 +125,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", content: "initial forum post" },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1"),
|
||||
@@ -127,7 +145,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "tagged post", appliedTags: ["tag1", "tag2"] },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1"),
|
||||
@@ -148,7 +166,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", appliedTags: ["tag1"] },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1"),
|
||||
@@ -162,7 +180,7 @@ describe("sendMessageDiscord", () => {
|
||||
const { rest, getMock, postMock } = makeDiscordRest();
|
||||
getMock.mockRejectedValue(new Error("lookup failed"));
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
|
||||
await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1"),
|
||||
expect.objectContaining({
|
||||
@@ -178,7 +196,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", type: ChannelType.PrivateThread },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
@@ -196,7 +214,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", content: "Hello thread!" },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
// First call: create thread
|
||||
@@ -223,7 +241,7 @@ describe("sendMessageDiscord", () => {
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1", content: "Discussion here" },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
// Should not detect channel type for message-attached threads
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
@@ -247,7 +265,7 @@ describe("sendMessageDiscord", () => {
|
||||
it("lists active threads by guild", async () => {
|
||||
const { rest, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValue({ threads: [] });
|
||||
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
|
||||
await listThreadsDiscord({ guildId: "g1" }, discordClientOpts(rest));
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
|
||||
});
|
||||
|
||||
@@ -256,7 +274,7 @@ describe("sendMessageDiscord", () => {
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.guildMember("g1", "u1"),
|
||||
@@ -272,8 +290,8 @@ describe("sendMessageDiscord", () => {
|
||||
const { rest, putMock, deleteMock } = makeDiscordRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" });
|
||||
await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" });
|
||||
await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest));
|
||||
await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest));
|
||||
expect(putMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
|
||||
expect(deleteMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
|
||||
});
|
||||
@@ -283,7 +301,7 @@ describe("sendMessageDiscord", () => {
|
||||
putMock.mockResolvedValue({});
|
||||
await banMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildBan("g1", "u1"),
|
||||
@@ -300,7 +318,7 @@ describe("listGuildEmojisDiscord", () => {
|
||||
it("lists emojis for a guild", async () => {
|
||||
const { rest, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
|
||||
await listGuildEmojisDiscord("g1", { rest, token: "t" });
|
||||
await listGuildEmojisDiscord("g1", discordClientOpts(rest));
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
|
||||
});
|
||||
});
|
||||
@@ -320,7 +338,7 @@ describe("uploadEmojiDiscord", () => {
|
||||
mediaUrl: "file:///tmp/party.png",
|
||||
roleIds: ["r1"],
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildEmojis("g1"),
|
||||
@@ -352,7 +370,7 @@ describe("uploadStickerDiscord", () => {
|
||||
tags: "👋",
|
||||
mediaUrl: "file:///tmp/wave.png",
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
discordClientOpts(rest),
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.guildStickers("g1"),
|
||||
@@ -383,6 +401,7 @@ describe("sendStickerDiscord", () => {
|
||||
const { rest, postMock } = makeDiscordRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendStickerDiscord("channel:789", ["123"], {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
@@ -415,6 +434,7 @@ describe("sendPollDiscord", () => {
|
||||
options: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
},
|
||||
@@ -473,6 +493,7 @@ describe("retry rate limits", () => {
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", "hello", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
@@ -493,6 +514,7 @@ describe("retry rate limits", () => {
|
||||
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||
|
||||
const promise = sendMessageDiscord("channel:789", "hello", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||
@@ -516,6 +538,7 @@ describe("retry rate limits", () => {
|
||||
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
@@ -528,9 +551,9 @@ describe("retry rate limits", () => {
|
||||
const { rest, postMock } = makeDiscordRest();
|
||||
postMock.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
await expect(sendMessageDiscord("channel:789", "hello", { rest, token: "t" })).rejects.toThrow(
|
||||
"network error",
|
||||
);
|
||||
await expect(
|
||||
sendMessageDiscord("channel:789", "hello", discordClientOpts(rest)),
|
||||
).rejects.toThrow("network error");
|
||||
expect(postMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -541,6 +564,7 @@ describe("retry rate limits", () => {
|
||||
putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
@@ -561,6 +585,7 @@ describe("retry rate limits", () => {
|
||||
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
|
||||
|
||||
const res = await sendMessageDiscord("channel:789", text, {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { serializePayload, type MessagePayloadObject, type RequestClient } from "@buape/carbon";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { maxBytesForKind } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
} from "./voice-message.js";
|
||||
|
||||
type DiscordSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
@@ -128,7 +128,7 @@ async function resolveDiscordSendTarget(
|
||||
to: string,
|
||||
opts: DiscordSendOpts,
|
||||
): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord send target resolution");
|
||||
const { rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
@@ -138,9 +138,9 @@ async function resolveDiscordSendTarget(
|
||||
export async function sendMessageDiscord(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: DiscordSendOpts = {},
|
||||
opts: DiscordSendOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord send");
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -201,6 +201,7 @@ export async function sendMessageDiscord(
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
@@ -255,6 +256,7 @@ export async function sendMessageDiscord(
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId: threadId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
@@ -312,6 +314,7 @@ export async function sendMessageDiscord(
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
@@ -327,7 +330,7 @@ export async function sendMessageDiscord(
|
||||
}
|
||||
|
||||
type DiscordWebhookSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
accountId?: string;
|
||||
@@ -423,7 +426,7 @@ export async function sendWebhookMessageDiscord(
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
opts: DiscordSendOpts & { content?: string },
|
||||
): Promise<DiscordSendResult> {
|
||||
const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext(
|
||||
to,
|
||||
@@ -446,7 +449,7 @@ export async function sendStickerDiscord(
|
||||
export async function sendPollDiscord(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
opts: DiscordSendOpts & { content?: string },
|
||||
): Promise<DiscordSendResult> {
|
||||
const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext(
|
||||
to,
|
||||
@@ -480,7 +483,7 @@ async function resolveDiscordStructuredSendContext(
|
||||
channelId: string;
|
||||
rewrittenContent?: string;
|
||||
}> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord structured send");
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -496,7 +499,7 @@ async function resolveDiscordStructuredSendContext(
|
||||
}
|
||||
|
||||
type VoiceMessageOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
@@ -532,7 +535,7 @@ async function materializeVoiceMessageInput(mediaUrl: string): Promise<{ filePat
|
||||
export async function sendVoiceMessageDiscord(
|
||||
to: string,
|
||||
audioPath: string,
|
||||
opts: VoiceMessageOpts = {},
|
||||
opts: VoiceMessageOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const { filePath: localInputPath } = await materializeVoiceMessageInput(audioPath);
|
||||
let oggPath: string | null = null;
|
||||
@@ -540,9 +543,10 @@ export async function sendVoiceMessageDiscord(
|
||||
let token: string | undefined;
|
||||
let rest: RequestClient | undefined;
|
||||
let channelId: string | undefined;
|
||||
let cfg: OpenClawConfig | undefined;
|
||||
|
||||
try {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
cfg = requireRuntimeConfig(opts.cfg, "Discord voice send");
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -585,9 +589,10 @@ export async function sendVoiceMessageDiscord(
|
||||
|
||||
return toDiscordSendResult(result, channelId);
|
||||
} catch (err) {
|
||||
if (channelId && rest && token) {
|
||||
if (channelId && rest && token && cfg) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
cfg,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
buildReactionIdentifier,
|
||||
createDiscordClient,
|
||||
@@ -17,7 +17,12 @@ function createDiscordReactionRuntimeClient(opts: DiscordReactionRuntimeContext)
|
||||
}
|
||||
|
||||
function resolveDiscordReactionClient(opts: DiscordReactOpts) {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Discord reactions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Discord reactions");
|
||||
return createDiscordClient(opts, cfg);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia;
|
||||
let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest;
|
||||
let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser;
|
||||
|
||||
const DISCORD_TEST_CFG = {
|
||||
channels: { discord: { token: "t" } },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
deleteMessageDiscord,
|
||||
@@ -62,6 +66,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:789", params.text, {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
replyTo: "orig-123",
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
});
|
||||
@@ -95,6 +100,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("channel:789", "hello world", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
@@ -118,6 +124,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:789", "ping @Alice", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
@@ -171,6 +178,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("channel:forum1", "Discussion topic\nBody of the post", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
|
||||
// Should POST to threads route, not channelMessages.
|
||||
@@ -190,6 +198,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("channel:forum1", "Topic", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
|
||||
@@ -220,6 +229,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:forum1", longText, {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
const firstBody = postMock.mock.calls[0]?.[1]?.body as {
|
||||
message?: { content?: string };
|
||||
@@ -237,6 +247,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("user:123", "hiya", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(postMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -254,13 +265,25 @@ describe("sendMessageDiscord", () => {
|
||||
it("rejects bare numeric IDs as ambiguous", async () => {
|
||||
const { rest } = makeDiscordRest();
|
||||
await expect(
|
||||
sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }),
|
||||
sendMessageDiscord("273512430271856640", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
}),
|
||||
).rejects.toThrow(/Ambiguous Discord recipient/);
|
||||
await expect(
|
||||
sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }),
|
||||
sendMessageDiscord("273512430271856640", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
}),
|
||||
).rejects.toThrow(/user:273512430271856640/);
|
||||
await expect(
|
||||
sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }),
|
||||
sendMessageDiscord("273512430271856640", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
}),
|
||||
).rejects.toThrow(/channel:273512430271856640/);
|
||||
});
|
||||
|
||||
@@ -289,7 +312,7 @@ describe("sendMessageDiscord", () => {
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await sendMessageDiscord("channel:789", "hello", { rest, token: "t" });
|
||||
await sendMessageDiscord("channel:789", "hello", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
@@ -303,6 +326,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("channel:789", "photo", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
expect(res.messageId).toBe("msg");
|
||||
@@ -327,6 +351,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:789", "photo", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/generated-image",
|
||||
filename: "renderable.png",
|
||||
});
|
||||
@@ -370,6 +395,7 @@ describe("sendMessageDiscord", () => {
|
||||
const res = await sendMessageDiscord("channel:789", "", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
expect(res.messageId).toBe("msg");
|
||||
@@ -384,6 +410,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:789", " spaced ", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
const body = postMock.mock.calls[0]?.[1]?.body;
|
||||
@@ -396,6 +423,7 @@ describe("sendMessageDiscord", () => {
|
||||
await sendMessageDiscord("channel:789", "hello", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
replyTo: "orig-123",
|
||||
});
|
||||
const body = postMock.mock.calls[0]?.[1]?.body;
|
||||
@@ -430,7 +458,7 @@ describe("reactMessageDiscord", () => {
|
||||
|
||||
it("reacts with unicode emoji", async () => {
|
||||
const { rest, putMock } = makeDiscordRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" });
|
||||
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
|
||||
);
|
||||
@@ -438,7 +466,7 @@ describe("reactMessageDiscord", () => {
|
||||
|
||||
it("normalizes variation selectors in unicode emoji", async () => {
|
||||
const { rest, putMock } = makeDiscordRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" });
|
||||
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"),
|
||||
);
|
||||
@@ -449,6 +477,7 @@ describe("reactMessageDiscord", () => {
|
||||
await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
|
||||
@@ -463,7 +492,7 @@ describe("removeReactionDiscord", () => {
|
||||
|
||||
it("removes a unicode emoji reaction", async () => {
|
||||
const { rest, deleteMock } = makeDiscordRest();
|
||||
await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t" });
|
||||
await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
|
||||
);
|
||||
@@ -486,6 +515,7 @@ describe("removeOwnReactionsDiscord", () => {
|
||||
const res = await removeOwnReactionsDiscord("chan1", "msg1", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toEqual({ ok: true, removed: ["✅", "party_blob:123"] });
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
@@ -516,6 +546,7 @@ describe("fetchReactionsDiscord", () => {
|
||||
const res = await fetchReactionsDiscord("chan1", "msg1", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toEqual([
|
||||
{
|
||||
@@ -558,6 +589,7 @@ describe("fetchChannelPermissionsDiscord", () => {
|
||||
const res = await fetchChannelPermissionsDiscord("chan1", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res.guildId).toBe("guild1");
|
||||
expect(res.permissions).toContain("ViewChannel");
|
||||
@@ -588,6 +620,7 @@ describe("fetchChannelPermissionsDiscord", () => {
|
||||
const res = await fetchChannelPermissionsDiscord("chan1", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res.permissions).toContain("Administrator");
|
||||
expect(res.permissions).toContain("ViewChannel");
|
||||
@@ -602,7 +635,11 @@ describe("readMessagesDiscord", () => {
|
||||
it("passes query params as an object", async () => {
|
||||
const { rest, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValue([]);
|
||||
await readMessagesDiscord("chan1", { limit: 5, before: "10" }, { rest, token: "t" });
|
||||
await readMessagesDiscord(
|
||||
"chan1",
|
||||
{ limit: 5, before: "10" },
|
||||
{ rest, token: "t", cfg: DISCORD_TEST_CFG },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as Record<string, unknown>;
|
||||
expect(options).toEqual({ limit: 5, before: "10" });
|
||||
@@ -617,7 +654,12 @@ describe("edit/delete message helpers", () => {
|
||||
it("edits message content", async () => {
|
||||
const { rest, patchMock } = makeDiscordRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await editMessageDiscord("chan1", "m1", { content: "hello" }, { rest, token: "t" });
|
||||
await editMessageDiscord(
|
||||
"chan1",
|
||||
"m1",
|
||||
{ content: "hello" },
|
||||
{ rest, token: "t", cfg: DISCORD_TEST_CFG },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("chan1", "m1"),
|
||||
expect.objectContaining({ body: { content: "hello" } }),
|
||||
@@ -627,7 +669,7 @@ describe("edit/delete message helpers", () => {
|
||||
it("deletes message", async () => {
|
||||
const { rest, deleteMock } = makeDiscordRest();
|
||||
deleteMock.mockResolvedValue({});
|
||||
await deleteMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
await deleteMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
expect(deleteMock).toHaveBeenCalledWith(Routes.channelMessage("chan1", "m1"));
|
||||
});
|
||||
});
|
||||
@@ -641,8 +683,8 @@ describe("pin helpers", () => {
|
||||
const { rest, putMock, deleteMock } = makeDiscordRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await pinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
await unpinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
await pinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
await unpinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
|
||||
expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
});
|
||||
@@ -658,7 +700,7 @@ describe("searchMessagesDiscord", () => {
|
||||
getMock.mockResolvedValue({ total_results: 0, messages: [] });
|
||||
await searchMessagesDiscord(
|
||||
{ guildId: "g1", content: "hello", limit: 5 },
|
||||
{ rest, token: "t" },
|
||||
{ rest, token: "t", cfg: DISCORD_TEST_CFG },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
expect(call?.[0]).toBe("/guilds/g1/messages/search?content=hello&limit=5");
|
||||
@@ -675,7 +717,7 @@ describe("searchMessagesDiscord", () => {
|
||||
authorIds: ["u1"],
|
||||
limit: 99,
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
{ rest, token: "t", cfg: DISCORD_TEST_CFG },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
expect(call?.[0]).toBe(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { PollLayoutType } from "discord-api-types/payloads/v10";
|
||||
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
|
||||
import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { extensionForMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
@@ -118,6 +119,7 @@ async function buildDiscordSendError(
|
||||
err: unknown,
|
||||
ctx: {
|
||||
channelId: string;
|
||||
cfg: OpenClawConfig;
|
||||
rest: RequestClient;
|
||||
token: string;
|
||||
hasMedia: boolean;
|
||||
@@ -142,6 +144,7 @@ async function buildDiscordSendError(
|
||||
const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, {
|
||||
rest: ctx.rest,
|
||||
token: ctx.token,
|
||||
cfg: ctx.cfg,
|
||||
});
|
||||
const current = new Set(permissions.permissions);
|
||||
const required = ["ViewChannel", "SendMessages"];
|
||||
|
||||
@@ -41,8 +41,11 @@ export async function sendIMessageOutbound(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function notifyIMessageApproval(id: string): Promise<void> {
|
||||
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
|
||||
export async function notifyIMessageApproval(params: {
|
||||
cfg: Parameters<typeof import("./accounts.js").resolveIMessageAccount>[0]["cfg"];
|
||||
id: string;
|
||||
}): Promise<void> {
|
||||
await sendMessageIMessage(params.id, PAIRING_APPROVED_MESSAGE, { config: params.cfg });
|
||||
}
|
||||
|
||||
export async function probeIMessageAccount(params?: {
|
||||
|
||||
@@ -233,8 +233,8 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
|
||||
text: {
|
||||
idLabel: "imessageSenderId",
|
||||
message: "OpenClaw: your access has been approved.",
|
||||
notify: async ({ id }) =>
|
||||
await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id),
|
||||
notify: async ({ id, cfg }) =>
|
||||
await (await loadIMessageChannelRuntime()).notifyIMessageApproval({ id, cfg }),
|
||||
},
|
||||
},
|
||||
security: imessageSecurityAdapter,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { loadConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock("../send.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./deliver.runtime.js", () => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveMarkdownTableMode: vi.fn(() => resolveMarkdownTableModeMock()),
|
||||
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
|
||||
resolveChunkMode: vi.fn(() => resolveChunkModeMock()),
|
||||
@@ -28,6 +27,7 @@ vi.mock("./deliver.runtime.js", () => ({
|
||||
let deliverReplies: typeof import("./deliver.js").deliverReplies;
|
||||
|
||||
describe("deliverReplies", () => {
|
||||
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
|
||||
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("deliverReplies", () => {
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverReplies({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second", replyToId: "reply-1" }],
|
||||
target: "chat_id:10",
|
||||
client,
|
||||
@@ -60,6 +61,7 @@ describe("deliverReplies", () => {
|
||||
"first",
|
||||
expect.objectContaining({
|
||||
client,
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
@@ -71,6 +73,7 @@ describe("deliverReplies", () => {
|
||||
"second",
|
||||
expect.objectContaining({
|
||||
client,
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
@@ -80,6 +83,7 @@ describe("deliverReplies", () => {
|
||||
|
||||
it("propagates payload replyToId through media sends", async () => {
|
||||
await deliverReplies({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [
|
||||
{
|
||||
text: "caption",
|
||||
@@ -103,6 +107,7 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
client,
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 8192,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
@@ -115,6 +120,7 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/b.jpg",
|
||||
client,
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 8192,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
@@ -133,6 +139,7 @@ describe("deliverReplies", () => {
|
||||
.mockResolvedValueOnce({ messageId: "imsg-2", sentText: "second" });
|
||||
|
||||
await deliverReplies({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second" }],
|
||||
target: "chat_id:30",
|
||||
client,
|
||||
@@ -163,6 +170,7 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
await deliverReplies({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
|
||||
target: "chat_id:40",
|
||||
client,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
@@ -9,7 +10,6 @@ import { sendMessageIMessage } from "../send.js";
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
convertMarkdownTables,
|
||||
loadConfig,
|
||||
resolveChunkMode,
|
||||
resolveMarkdownTableMode,
|
||||
} from "./deliver.runtime.js";
|
||||
@@ -17,6 +17,7 @@ import type { SentMessageCache } from "./echo-cache.js";
|
||||
import { sanitizeOutboundText } from "./sanitize-outbound.js";
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
||||
@@ -29,7 +30,7 @@ export async function deliverReplies(params: {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||
params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const cfg = loadConfig();
|
||||
const { cfg } = params;
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
@@ -47,6 +48,7 @@ export async function deliverReplies(params: {
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
const sent = await sendMessageIMessage(target, chunk, {
|
||||
config: params.cfg,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
@@ -60,6 +62,7 @@ export async function deliverReplies(params: {
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const sent = await sendMessageIMessage(target, caption ?? "", {
|
||||
config: params.cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
client,
|
||||
|
||||
@@ -352,6 +352,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageIMessage(sender, text, {
|
||||
config: cfg,
|
||||
client: getActiveClient(),
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
@@ -450,6 +451,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
return;
|
||||
}
|
||||
await deliverReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target,
|
||||
client: getActiveClient(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -22,7 +22,7 @@ export type IMessageSendOpts = {
|
||||
timeoutMs?: number;
|
||||
chatId?: number;
|
||||
client?: IMessageRpcClient;
|
||||
config?: ReturnType<typeof loadConfig>;
|
||||
config: OpenClawConfig;
|
||||
account?: ResolvedIMessageAccount;
|
||||
resolveAttachmentImpl?: (
|
||||
mediaUrl: string,
|
||||
@@ -97,9 +97,9 @@ function resolveDeliveredIMessageText(text: string, mediaContentType?: string):
|
||||
export async function sendMessageIMessage(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: IMessageSendOpts = {},
|
||||
opts: IMessageSendOpts,
|
||||
): Promise<IMessageSendResult> {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.config, "iMessage send");
|
||||
const account =
|
||||
opts.account ??
|
||||
resolveIMessageAccount({
|
||||
|
||||
@@ -322,13 +322,15 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
|
||||
idLabel: "ircUser",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
|
||||
notify: async ({ id, message }) => {
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const target = normalizePairingTarget(id);
|
||||
if (!target) {
|
||||
throw new Error(`invalid IRC pairing id: ${id}`);
|
||||
}
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
await sendMessageIrc(target, message);
|
||||
await sendMessageIrc(target, message, {
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ function resolveIrcEffectiveAllowlists(params: {
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
cfg: CoreConfig;
|
||||
target: string;
|
||||
accountId: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
@@ -70,6 +71,7 @@ async function deliverIrcReply(params: {
|
||||
await params.sendReply(params.target, text, replyToId);
|
||||
} else {
|
||||
await sendMessageIrc(params.target, text, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
@@ -218,6 +220,7 @@ export async function handleIrcInbound(params: {
|
||||
sendPairingReply: async (text) => {
|
||||
await deliverIrcReply({
|
||||
payload: { text },
|
||||
cfg: config,
|
||||
target: message.senderNick,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
@@ -340,6 +343,7 @@ export async function handleIrcInbound(params: {
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
cfg: config,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
|
||||
@@ -108,30 +108,19 @@ describe("sendMessageIrc cfg threading", () => {
|
||||
expect(result.messageId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to runtime config when cfg is omitted", async () => {
|
||||
const runtimeCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
it("fails hard when cfg is omitted", async () => {
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
await sendMessageIrc("#ops", "ping", { client });
|
||||
await expect(sendMessageIrc("#ops", "ping", { client } as never)).rejects.toThrow(
|
||||
"IRC send requires a resolved runtime config",
|
||||
);
|
||||
|
||||
expect(hoisted.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#ops", "ping");
|
||||
expect(hoisted.record).toHaveBeenCalledWith({
|
||||
channel: "irc",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(client.sendPrivmsg).not.toHaveBeenCalled();
|
||||
expect(hoisted.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import type { IrcClient } from "./client.js";
|
||||
@@ -10,7 +10,7 @@ import { getIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type SendIrcOptions = {
|
||||
cfg?: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
target?: string;
|
||||
@@ -51,10 +51,9 @@ function resolveTarget(to: string, opts?: SendIrcOptions): string {
|
||||
export async function sendMessageIrc(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SendIrcOptions = {},
|
||||
opts: SendIrcOptions,
|
||||
): Promise<SendIrcResult> {
|
||||
const runtime = getIrcRuntime();
|
||||
const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig;
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "IRC send") as CoreConfig;
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
|
||||
@@ -26,7 +26,9 @@ const createLocationMessage = (location: {
|
||||
});
|
||||
|
||||
describe("deliverLineAutoReply", () => {
|
||||
const LINE_TEST_CFG = { channels: { line: { accounts: { acc: {} } } } };
|
||||
const baseDeliveryParams = {
|
||||
cfg: LINE_TEST_CFG,
|
||||
to: "line:user:1",
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
@@ -89,13 +91,14 @@ describe("deliverLineAutoReply", () => {
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(replyMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith("token", [{ type: "text", text: "hello" }], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "acc",
|
||||
});
|
||||
expect(pushMessagesLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(createQuickReplyItems).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -128,7 +131,7 @@ describe("deliverLineAutoReply", () => {
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ accountId: "acc" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(pushMessagesLine).not.toHaveBeenCalled();
|
||||
expect(createQuickReplyItems).toHaveBeenCalledWith(["A"]);
|
||||
@@ -160,7 +163,7 @@ describe("deliverLineAutoReply", () => {
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
expect(replyMessageLine).toHaveBeenCalledWith(
|
||||
"token",
|
||||
@@ -171,7 +174,7 @@ describe("deliverLineAutoReply", () => {
|
||||
quickReply: { items: ["A"] },
|
||||
},
|
||||
],
|
||||
{ accountId: "acc" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
const pushOrder = pushMessagesLine.mock.invocationCallOrder[0];
|
||||
const replyOrder = replyMessageLine.mock.invocationCallOrder[0];
|
||||
@@ -203,7 +206,7 @@ describe("deliverLineAutoReply", () => {
|
||||
expect(pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:1",
|
||||
[createFlexMessage("Card", { type: "bubble" })],
|
||||
{ accountId: "acc" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "acc" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { FlexContainer } from "./flex-templates.js";
|
||||
@@ -17,7 +18,7 @@ export type LineAutoReplyDeps = {
|
||||
pushMessagesLine: (
|
||||
to: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts?: { accountId?: string },
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createFlexMessage: (altText: string, contents: FlexContainer) => messagingApi.FlexMessage;
|
||||
createImageMessage: (
|
||||
@@ -46,6 +47,7 @@ export async function deliverLineAutoReply(params: {
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed: boolean;
|
||||
accountId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
textLimit: number;
|
||||
deps: LineAutoReplyDeps;
|
||||
}): Promise<{ replyTokenUsed: boolean }> {
|
||||
@@ -58,6 +60,7 @@ export async function deliverLineAutoReply(params: {
|
||||
}
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
@@ -76,6 +79,7 @@ export async function deliverLineAutoReply(params: {
|
||||
const replyBatch = remaining.slice(0, 5);
|
||||
try {
|
||||
await deps.replyMessageLine(replyToken, replyBatch, {
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -145,6 +149,7 @@ export async function deliverLineAutoReply(params: {
|
||||
quickReplies: lineData.quickReplies,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
replyMessageLine: deps.replyMessageLine,
|
||||
pushMessageLine: deps.pushMessageLine,
|
||||
|
||||
@@ -209,6 +209,7 @@ async function sendLinePairingReply(params: {
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(replyToken, [{ type: "text", text }], {
|
||||
cfg: context.cfg,
|
||||
accountId: context.account.accountId,
|
||||
channelAccessToken: context.account.channelAccessToken,
|
||||
});
|
||||
@@ -219,6 +220,7 @@ async function sendLinePairingReply(params: {
|
||||
}
|
||||
try {
|
||||
await pushMessageLine(`line:${senderId}`, text, {
|
||||
cfg: context.cfg,
|
||||
accountId: context.account.accountId,
|
||||
channelAccessToken: context.account.channelAccessToken,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ describe("linePlugin outbound.sendPayload", () => {
|
||||
"line:user:1",
|
||||
"OpenClaw: your access has been approved.",
|
||||
{
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
channelAccessToken: "token-primary",
|
||||
},
|
||||
|
||||
@@ -141,6 +141,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
getLineRuntime().channel.line?.pushMessageLine ??
|
||||
(await loadLineChannelRuntime()).pushMessageLine;
|
||||
await pushMessageLine(id, message, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
channelAccessToken: account.channelAccessToken,
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ export function clearLineRuntimeStateForTests() {
|
||||
}
|
||||
|
||||
function startLineLoadingKeepalive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
userId: string;
|
||||
accountId?: string;
|
||||
intervalMs?: number;
|
||||
@@ -116,6 +117,7 @@ function startLineLoadingKeepalive(params: {
|
||||
return;
|
||||
}
|
||||
void showLoadingAnimation(params.userId, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
loadingSeconds,
|
||||
}).catch(() => {});
|
||||
@@ -189,11 +191,15 @@ export async function monitorLineProvider(
|
||||
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
|
||||
|
||||
const displayNamePromise = ctx.userId
|
||||
? getUserDisplayName(ctx.userId, { accountId: ctx.accountId })
|
||||
? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId })
|
||||
: Promise.resolve(ctxPayload.From);
|
||||
|
||||
const stopLoading = shouldShowLoading
|
||||
? startLineLoadingKeepalive({ userId: ctx.userId!, accountId: ctx.accountId })
|
||||
? startLineLoadingKeepalive({
|
||||
cfg: config,
|
||||
userId: ctx.userId!,
|
||||
accountId: ctx.accountId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const displayName = await displayNamePromise;
|
||||
@@ -218,7 +224,10 @@ export async function monitorLineProvider(
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
|
||||
if (ctx.userId && !ctx.isGroup) {
|
||||
void showLoadingAnimation(ctx.userId, { accountId: ctx.accountId }).catch(() => {});
|
||||
void showLoadingAnimation(ctx.userId, {
|
||||
cfg: config,
|
||||
accountId: ctx.accountId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
|
||||
@@ -228,6 +237,7 @@ export async function monitorLineProvider(
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
accountId: ctx.accountId,
|
||||
cfg: config,
|
||||
textLimit,
|
||||
deps: {
|
||||
buildTemplateMessageFromPayload,
|
||||
@@ -280,7 +290,7 @@ export async function monitorLineProvider(
|
||||
await replyMessageLine(
|
||||
replyToken,
|
||||
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
|
||||
{ accountId: ctx.accountId },
|
||||
{ cfg: config, accountId: ctx.accountId },
|
||||
);
|
||||
} catch (replyErr) {
|
||||
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
|
||||
const LINE_TEST_CFG = { channels: { line: { channelAccessToken: "line-token" } } };
|
||||
|
||||
function createReplyChunksHarness() {
|
||||
const replyMessageLine = vi.fn(async () => ({}));
|
||||
const pushMessageLine = vi.fn(async () => ({}));
|
||||
@@ -33,6 +35,7 @@ describe("sendLineReplyChunks", () => {
|
||||
quickReplies: ["A", "B"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
@@ -50,7 +53,7 @@ describe("sendLineReplyChunks", () => {
|
||||
{ type: "text", text: "two" },
|
||||
{ type: "text", text: "three" },
|
||||
],
|
||||
{ accountId: "default" },
|
||||
{ cfg: LINE_TEST_CFG, accountId: "default" },
|
||||
);
|
||||
expect(pushMessageLine).not.toHaveBeenCalled();
|
||||
expect(pushTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
@@ -71,6 +74,7 @@ describe("sendLineReplyChunks", () => {
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
@@ -99,6 +103,7 @@ describe("sendLineReplyChunks", () => {
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
@@ -116,12 +121,16 @@ describe("sendLineReplyChunks", () => {
|
||||
{ type: "text", text: "4" },
|
||||
{ type: "text", text: "5" },
|
||||
],
|
||||
{ accountId: undefined },
|
||||
{ cfg: LINE_TEST_CFG, accountId: undefined },
|
||||
);
|
||||
expect(pushMessageLine).toHaveBeenCalledTimes(1);
|
||||
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", { accountId: undefined });
|
||||
expect(pushMessageLine).toHaveBeenCalledWith("line:group:1", "6", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledTimes(1);
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "7", ["A"], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(createTextMessageWithQuickReplies).not.toHaveBeenCalled();
|
||||
@@ -143,6 +152,7 @@ describe("sendLineReplyChunks", () => {
|
||||
quickReplies: ["A"],
|
||||
replyToken: "token",
|
||||
replyTokenUsed: false,
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
@@ -154,12 +164,15 @@ describe("sendLineReplyChunks", () => {
|
||||
expect(result.replyTokenUsed).toBe(true);
|
||||
expect(onReplyError).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(1, "line:group:1", "1", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushMessageLine).toHaveBeenNthCalledWith(2, "line:group:1", "2", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(pushTextMessageWithQuickReplies).toHaveBeenCalledWith("line:group:1", "3", ["A"], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { messagingApi } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
export type LineReplyMessage = messagingApi.TextMessage;
|
||||
|
||||
@@ -8,18 +9,23 @@ export type SendLineReplyChunksParams = {
|
||||
quickReplies?: string[];
|
||||
replyToken?: string | null;
|
||||
replyTokenUsed?: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
replyMessageLine: (
|
||||
replyToken: string,
|
||||
messages: messagingApi.Message[],
|
||||
opts?: { accountId?: string },
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
pushMessageLine: (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
pushMessageLine: (to: string, text: string, opts?: { accountId?: string }) => Promise<unknown>;
|
||||
pushTextMessageWithQuickReplies: (
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplies: string[],
|
||||
opts?: { accountId?: string },
|
||||
opts: { cfg: OpenClawConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
createTextMessageWithQuickReplies: (text: string, quickReplies: string[]) => LineReplyMessage;
|
||||
onReplyError?: (err: unknown) => void;
|
||||
@@ -54,6 +60,7 @@ export async function sendLineReplyChunks(
|
||||
}
|
||||
|
||||
await params.replyMessageLine(params.replyToken, replyMessages, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
replyTokenUsed = true;
|
||||
@@ -65,10 +72,11 @@ export async function sendLineReplyChunks(
|
||||
params.to,
|
||||
remaining[i],
|
||||
params.quickReplies!,
|
||||
{ accountId: params.accountId },
|
||||
{ cfg: params.cfg, accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, remaining[i], {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
@@ -88,10 +96,11 @@ export async function sendLineReplyChunks(
|
||||
params.to,
|
||||
params.chunks[i],
|
||||
params.quickReplies!,
|
||||
{ accountId: params.accountId },
|
||||
{ cfg: params.cfg, accountId: params.accountId },
|
||||
);
|
||||
} else {
|
||||
await params.pushMessageLine(params.to, params.chunks[i], {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
@@ -37,14 +37,15 @@ export interface CreateRichMenuParams {
|
||||
}
|
||||
|
||||
interface RichMenuOpts {
|
||||
cfg: OpenClawConfig;
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient {
|
||||
function getClient(opts: RichMenuOpts): messagingApi.MessagingApiClient {
|
||||
const account = resolveLineAccount({
|
||||
cfg: loadConfig(),
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
@@ -54,9 +55,9 @@ function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient {
|
||||
function getBlobClient(opts: RichMenuOpts): messagingApi.MessagingApiBlobClient {
|
||||
const account = resolveLineAccount({
|
||||
cfg: loadConfig(),
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
|
||||
@@ -76,7 +77,7 @@ function chunkUserIds(userIds: string[]): string[][] {
|
||||
|
||||
export async function createRichMenu(
|
||||
menu: CreateRichMenuParams,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<string> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -100,7 +101,7 @@ export async function createRichMenu(
|
||||
export async function uploadRichMenuImage(
|
||||
richMenuId: string,
|
||||
imagePath: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const blobClient = getBlobClient(opts);
|
||||
|
||||
@@ -116,10 +117,7 @@ export async function uploadRichMenuImage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultRichMenu(
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
export async function setDefaultRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.setDefaultRichMenu(richMenuId);
|
||||
|
||||
@@ -128,7 +126,7 @@ export async function setDefaultRichMenu(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise<void> {
|
||||
export async function cancelDefaultRichMenu(opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.cancelDefaultRichMenu();
|
||||
|
||||
@@ -137,7 +135,7 @@ export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise<string | null> {
|
||||
export async function getDefaultRichMenuId(opts: RichMenuOpts): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
try {
|
||||
@@ -151,7 +149,7 @@ export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise<str
|
||||
export async function linkRichMenuToUser(
|
||||
userId: string,
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.linkRichMenuIdToUser(userId, richMenuId);
|
||||
@@ -164,7 +162,7 @@ export async function linkRichMenuToUser(
|
||||
export async function linkRichMenuToUsers(
|
||||
userIds: string[],
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -180,10 +178,7 @@ export async function linkRichMenuToUsers(
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkRichMenuFromUser(
|
||||
userId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
): Promise<void> {
|
||||
export async function unlinkRichMenuFromUser(userId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.unlinkRichMenuIdFromUser(userId);
|
||||
|
||||
@@ -194,7 +189,7 @@ export async function unlinkRichMenuFromUser(
|
||||
|
||||
export async function unlinkRichMenuFromUsers(
|
||||
userIds: string[],
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -211,7 +206,7 @@ export async function unlinkRichMenuFromUsers(
|
||||
|
||||
export async function getRichMenuIdOfUser(
|
||||
userId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<string | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -223,7 +218,7 @@ export async function getRichMenuIdOfUser(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRichMenuList(opts: RichMenuOpts = {}): Promise<RichMenuResponse[]> {
|
||||
export async function getRichMenuList(opts: RichMenuOpts): Promise<RichMenuResponse[]> {
|
||||
const client = getClient(opts);
|
||||
const response = await client.getRichMenuList();
|
||||
return response.richmenus ?? [];
|
||||
@@ -231,7 +226,7 @@ export async function getRichMenuList(opts: RichMenuOpts = {}): Promise<RichMenu
|
||||
|
||||
export async function getRichMenu(
|
||||
richMenuId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<RichMenuResponse | null> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -242,7 +237,7 @@ export async function getRichMenu(
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}): Promise<void> {
|
||||
export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.deleteRichMenu(richMenuId);
|
||||
|
||||
@@ -254,7 +249,7 @@ export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}
|
||||
export async function createRichMenuAlias(
|
||||
richMenuId: string,
|
||||
aliasId: string,
|
||||
opts: RichMenuOpts = {},
|
||||
opts: RichMenuOpts,
|
||||
): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
|
||||
@@ -268,7 +263,7 @@ export async function createRichMenuAlias(
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts = {}): Promise<void> {
|
||||
export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts): Promise<void> {
|
||||
const client = getClient(opts);
|
||||
await client.deleteRichMenuAlias(aliasId);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const {
|
||||
showLoadingAnimationMock,
|
||||
getProfileMock,
|
||||
MessagingApiClientMock,
|
||||
loadConfigMock,
|
||||
requireRuntimeConfigMock,
|
||||
resolveLineAccountMock,
|
||||
resolveLineChannelAccessTokenMock,
|
||||
recordChannelActivityMock,
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
getProfile: getProfileMock,
|
||||
};
|
||||
});
|
||||
const loadConfigMock = vi.fn(() => ({}));
|
||||
const requireRuntimeConfigMock = vi.fn((cfg: unknown) => cfg ?? {});
|
||||
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
|
||||
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
|
||||
const recordChannelActivityMock = vi.fn();
|
||||
@@ -37,7 +37,7 @@ const {
|
||||
showLoadingAnimationMock,
|
||||
getProfileMock,
|
||||
MessagingApiClientMock,
|
||||
loadConfigMock,
|
||||
requireRuntimeConfigMock,
|
||||
resolveLineAccountMock,
|
||||
resolveLineChannelAccessTokenMock,
|
||||
recordChannelActivityMock,
|
||||
@@ -51,7 +51,7 @@ vi.mock("@line/bot-sdk", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
requireRuntimeConfig: requireRuntimeConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
@@ -82,6 +82,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
|
||||
let sendModule: typeof import("./send.js");
|
||||
|
||||
const LINE_TEST_CFG = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("LINE send helpers", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -90,7 +100,7 @@ describe("LINE send helpers", () => {
|
||||
showLoadingAnimationMock.mockReset();
|
||||
getProfileMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
loadConfigMock.mockReset();
|
||||
requireRuntimeConfigMock.mockClear();
|
||||
resolveLineAccountMock.mockReset();
|
||||
resolveLineChannelAccessTokenMock.mockReset();
|
||||
recordChannelActivityMock.mockReset();
|
||||
@@ -105,7 +115,7 @@ describe("LINE send helpers", () => {
|
||||
getProfile: getProfileMock,
|
||||
};
|
||||
});
|
||||
loadConfigMock.mockReturnValue({});
|
||||
requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
|
||||
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
|
||||
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
|
||||
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
|
||||
@@ -134,7 +144,7 @@ describe("LINE send helpers", () => {
|
||||
"line:user:U123",
|
||||
"https://example.com/original.jpg",
|
||||
undefined,
|
||||
{ verbose: true },
|
||||
{ cfg: LINE_TEST_CFG, verbose: true },
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||||
@@ -158,6 +168,7 @@ describe("LINE send helpers", () => {
|
||||
|
||||
it("replies when reply token is provided", async () => {
|
||||
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
replyToken: "reply-token",
|
||||
mediaUrl: "https://example.com/media.jpg",
|
||||
verbose: true,
|
||||
@@ -185,6 +196,7 @@ describe("LINE send helpers", () => {
|
||||
|
||||
it("sends video with explicit image preview URL", async () => {
|
||||
await sendModule.sendMessageLine("line:user:U100", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
@@ -211,6 +223,7 @@ describe("LINE send helpers", () => {
|
||||
it("throws when video preview URL is missing", async () => {
|
||||
await expect(
|
||||
sendModule.sendMessageLine("line:user:U200", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
}),
|
||||
@@ -224,6 +237,7 @@ describe("LINE send helpers", () => {
|
||||
|
||||
await expect(
|
||||
sendModule.sendMessageLine("line:user:U200", "Image", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://127.0.0.1/image.jpg",
|
||||
}),
|
||||
).rejects.toThrow(/private network/i);
|
||||
@@ -233,6 +247,7 @@ describe("LINE send helpers", () => {
|
||||
|
||||
it("omits trackingId for non-user destinations", async () => {
|
||||
await sendModule.sendMessageLine("line:group:C100", "Video", {
|
||||
cfg: LINE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/video.mp4",
|
||||
mediaKind: "video",
|
||||
previewImageUrl: "https://example.com/preview.jpg",
|
||||
@@ -256,7 +271,7 @@ describe("LINE send helpers", () => {
|
||||
});
|
||||
|
||||
it("throws when push messages are empty", async () => {
|
||||
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
|
||||
await expect(sendModule.pushMessagesLine("U123", [], { cfg: LINE_TEST_CFG })).rejects.toThrow(
|
||||
"Message must be non-empty for LINE sends",
|
||||
);
|
||||
});
|
||||
@@ -273,7 +288,9 @@ describe("LINE send helpers", () => {
|
||||
pushMessageMock.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(
|
||||
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]),
|
||||
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }], {
|
||||
cfg: LINE_TEST_CFG,
|
||||
}),
|
||||
).rejects.toThrow("LINE push failed");
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
@@ -287,8 +304,8 @@ describe("LINE send helpers", () => {
|
||||
pictureUrl: "https://example.com/peter.jpg",
|
||||
});
|
||||
|
||||
const first = await sendModule.getUserProfile("U-cache");
|
||||
const second = await sendModule.getUserProfile("U-cache");
|
||||
const first = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||||
const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||||
|
||||
expect(first).toEqual({
|
||||
displayName: "Peter",
|
||||
@@ -301,7 +318,9 @@ describe("LINE send helpers", () => {
|
||||
it("continues when loading animation is unsupported", async () => {
|
||||
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
|
||||
|
||||
await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined();
|
||||
await expect(
|
||||
sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("line: loading animation failed (non-fatal)"),
|
||||
@@ -313,6 +332,7 @@ describe("LINE send helpers", () => {
|
||||
"U-quick",
|
||||
"Pick one",
|
||||
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
|
||||
{ cfg: LINE_TEST_CFG },
|
||||
);
|
||||
|
||||
expect(pushMessageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { messagingApi } from "@line/bot-sdk";
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveLineAccount } from "./accounts.js";
|
||||
@@ -26,7 +26,7 @@ const userProfileCache = new Map<
|
||||
const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface LineSendOpts {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
channelAccessToken?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
@@ -77,7 +77,7 @@ function createLineMessagingClient(opts: LineClientOpts): {
|
||||
account: ReturnType<typeof resolveLineAccount>;
|
||||
client: messagingApi.MessagingApiClient;
|
||||
} {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "LINE send");
|
||||
const account = resolveLineAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -179,7 +179,7 @@ function recordLineOutboundActivity(accountId: string): void {
|
||||
async function pushLineMessages(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
behavior: LinePushBehavior = {},
|
||||
): Promise<LineSendResult> {
|
||||
if (messages.length === 0) {
|
||||
@@ -219,7 +219,7 @@ async function pushLineMessages(
|
||||
async function replyLineMessages(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
behavior: LineReplyBehavior = {},
|
||||
): Promise<void> {
|
||||
const { account, client } = createLineMessagingClient(opts);
|
||||
@@ -242,7 +242,7 @@ async function replyLineMessages(
|
||||
export async function sendMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts = {},
|
||||
opts: LineSendOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const chatId = normalizeTarget(to);
|
||||
const messages: Message[] = [];
|
||||
@@ -303,7 +303,7 @@ export async function sendMessageLine(
|
||||
export async function pushMessageLine(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: LineSendOpts = {},
|
||||
opts: LineSendOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
|
||||
}
|
||||
@@ -311,7 +311,7 @@ export async function pushMessageLine(
|
||||
export async function replyMessageLine(
|
||||
replyToken: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<void> {
|
||||
await replyLineMessages(replyToken, messages, opts);
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export async function replyMessageLine(
|
||||
export async function pushMessagesLine(
|
||||
to: string,
|
||||
messages: Message[],
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, messages, opts, {
|
||||
errorContext: "push message",
|
||||
@@ -340,8 +340,8 @@ export function createFlexMessage(
|
||||
export async function pushImageMessage(
|
||||
to: string,
|
||||
originalContentUrl: string,
|
||||
previewImageUrl?: string,
|
||||
opts: LinePushOpts = {},
|
||||
previewImageUrl: string | undefined,
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
await validateLineMediaUrl(originalContentUrl);
|
||||
if (previewImageUrl) {
|
||||
@@ -360,7 +360,7 @@ export async function pushLocationMessage(
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
},
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [createLocationMessage(location)], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed location to ${chatId}`,
|
||||
@@ -371,7 +371,7 @@ export async function pushFlexMessage(
|
||||
to: string,
|
||||
altText: string,
|
||||
contents: FlexContainer,
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const flexMessage: FlexMessage = {
|
||||
type: "flex",
|
||||
@@ -388,7 +388,7 @@ export async function pushFlexMessage(
|
||||
export async function pushTemplateMessage(
|
||||
to: string,
|
||||
template: TemplateMessage,
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
return pushLineMessages(to, [template], opts, {
|
||||
verboseMessage: (chatId) => `line: pushed template message to ${chatId}`,
|
||||
@@ -399,7 +399,7 @@ export async function pushTextMessageWithQuickReplies(
|
||||
to: string,
|
||||
text: string,
|
||||
quickReplyLabels: string[],
|
||||
opts: LinePushOpts = {},
|
||||
opts: LinePushOpts,
|
||||
): Promise<LineSendResult> {
|
||||
const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
|
||||
|
||||
@@ -433,7 +433,7 @@ export function createTextMessageWithQuickReplies(
|
||||
|
||||
export async function showLoadingAnimation(
|
||||
chatId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {},
|
||||
opts: LineClientOpts & { loadingSeconds?: number },
|
||||
): Promise<void> {
|
||||
const { client } = createLineMessagingClient(opts);
|
||||
|
||||
@@ -450,7 +450,7 @@ export async function showLoadingAnimation(
|
||||
|
||||
export async function getUserProfile(
|
||||
userId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {},
|
||||
opts: LineClientOpts & { useCache?: boolean },
|
||||
): Promise<{ displayName: string; pictureUrl?: string } | null> {
|
||||
const useCache = opts.useCache ?? true;
|
||||
|
||||
@@ -482,10 +482,7 @@ export async function getUserProfile(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(
|
||||
userId: string,
|
||||
opts: { channelAccessToken?: string; accountId?: string } = {},
|
||||
): Promise<string> {
|
||||
export async function getUserDisplayName(userId: string, opts: LineClientOpts): Promise<string> {
|
||||
const profile = await getUserProfile(userId, opts);
|
||||
return profile?.displayName ?? userId;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type ProbeMatrix = (params: {
|
||||
type SendMessageMatrix = (
|
||||
to: string,
|
||||
message: string,
|
||||
options?: { accountId?: string },
|
||||
options: { cfg: CoreConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function createMatrixProbeAccount(params: {
|
||||
@@ -80,13 +80,18 @@ export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) {
|
||||
notify: async ({
|
||||
id,
|
||||
message,
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
id: string;
|
||||
message: string;
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
await sendMessageMatrix(`user:${id}`, message, accountId ? { accountId } : {});
|
||||
await sendMessageMatrix(`user:${id}`, message, {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("matrix account path propagation", () => {
|
||||
);
|
||||
|
||||
await pairingText.notify({
|
||||
cfg: {} as never,
|
||||
id: "@user:example.org",
|
||||
message: pairingText.message,
|
||||
accountId: "poe",
|
||||
@@ -68,7 +69,7 @@ describe("matrix account path propagation", () => {
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
|
||||
"user:@user:example.org",
|
||||
expect.any(String),
|
||||
{ accountId: "poe" },
|
||||
{ cfg: {}, accountId: "poe" },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ const {
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
@@ -65,14 +67,20 @@ describe("action client helpers", () => {
|
||||
it("stops one-off shared clients when no active monitor client is registered", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
await expectOneOffSharedMatrixClient();
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips one-off room preparation when readiness is disabled", async () => {
|
||||
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
|
||||
await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default", readiness: "none" },
|
||||
async () => {},
|
||||
);
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
@@ -81,7 +89,7 @@ describe("action client helpers", () => {
|
||||
});
|
||||
|
||||
it("starts one-off clients when started readiness is required", async () => {
|
||||
await withStartedActionClient({ accountId: "default" }, async () => {});
|
||||
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {});
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.start).toHaveBeenCalledTimes(1);
|
||||
@@ -93,10 +101,13 @@ describe("action client helpers", () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
|
||||
@@ -107,7 +118,7 @@ describe("action client helpers", () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
});
|
||||
|
||||
@@ -143,7 +154,7 @@ describe("action client helpers", () => {
|
||||
encryption: true,
|
||||
},
|
||||
});
|
||||
await withResolvedActionClient({}, async () => {});
|
||||
await withResolvedActionClient({ cfg: loadConfigMock() as never }, async () => {});
|
||||
|
||||
await expectOneOffSharedMatrixClient({
|
||||
cfg: loadConfigMock(),
|
||||
@@ -172,10 +183,13 @@ describe("action client helpers", () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
});
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
@@ -186,7 +200,7 @@ describe("action client helpers", () => {
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedActionClient({ accountId: "default" }, async () => {
|
||||
withResolvedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
@@ -201,7 +215,7 @@ describe("action client helpers", () => {
|
||||
|
||||
const result = await withResolvedRoomAction(
|
||||
"room:#ops:example.org",
|
||||
{ accountId: "default" },
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client, resolvedRoom) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return resolvedRoom;
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { MatrixClient } from "../sdk.js";
|
||||
import * as sendModule from "../send.js";
|
||||
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
|
||||
|
||||
const MATRIX_ACTION_TEST_CFG = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
function installMatrixActionTestRuntime(): void {
|
||||
setMatrixRuntime({
|
||||
config: {
|
||||
@@ -110,13 +116,15 @@ describe("matrix message actions", () => {
|
||||
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
|
||||
|
||||
try {
|
||||
const cfg = {} as never;
|
||||
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
|
||||
cfg,
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
|
||||
cfg: undefined,
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
client: undefined,
|
||||
timeoutMs: 12_345,
|
||||
@@ -137,7 +145,7 @@ describe("matrix message actions", () => {
|
||||
"!room:example.org",
|
||||
"$original",
|
||||
"hello @alice:example.org and @bob:example.org",
|
||||
{ client },
|
||||
{ cfg: MATRIX_ACTION_TEST_CFG, client },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
@@ -162,7 +170,7 @@ describe("matrix message actions", () => {
|
||||
"!room:example.org",
|
||||
"$original",
|
||||
"hello again @alice:example.org",
|
||||
{ client },
|
||||
{ cfg: MATRIX_ACTION_TEST_CFG, client },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
|
||||
@@ -22,6 +22,9 @@ export async function sendMatrixMessage(
|
||||
audioAsVoice?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!opts.cfg) {
|
||||
throw new Error("Matrix message actions require a resolved runtime config.");
|
||||
}
|
||||
return await sendMessageMatrix(to, content, {
|
||||
cfg: opts.cfg,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
@@ -41,6 +44,9 @@ export async function editMatrixMessage(
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
if (!opts.cfg) {
|
||||
throw new Error("Matrix message actions require a resolved runtime config.");
|
||||
}
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix edit requires content");
|
||||
|
||||
@@ -16,6 +16,16 @@ vi.mock("../../runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
|
||||
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
|
||||
@@ -61,7 +71,9 @@ describe("matrix verification actions", () => {
|
||||
return await run({ crypto: null });
|
||||
});
|
||||
|
||||
await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow(
|
||||
await expect(
|
||||
listMatrixVerifications({ cfg: loadConfigMock(), accountId: "ops" }),
|
||||
).rejects.toThrow(
|
||||
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
||||
);
|
||||
});
|
||||
@@ -83,7 +95,7 @@ describe("matrix verification actions", () => {
|
||||
return await run({ crypto: null });
|
||||
});
|
||||
|
||||
await expect(listMatrixVerifications()).rejects.toThrow(
|
||||
await expect(listMatrixVerifications({ cfg: loadConfigMock() })).rejects.toThrow(
|
||||
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
|
||||
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
|
||||
@@ -10,7 +10,12 @@ function requireCrypto(
|
||||
opts: MatrixActionClientOpts,
|
||||
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
|
||||
if (!client.crypto) {
|
||||
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Matrix verification actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix verification actions") as CoreConfig;
|
||||
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
|
||||
}
|
||||
return client.crypto;
|
||||
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
@@ -56,6 +58,7 @@ describe("client bootstrap", () => {
|
||||
|
||||
await expect(
|
||||
resolveRuntimeMatrixClientWithReadiness({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "default",
|
||||
readiness: "prepared",
|
||||
}),
|
||||
@@ -72,6 +75,7 @@ describe("client bootstrap", () => {
|
||||
await expect(
|
||||
withResolvedRuntimeMatrixClient(
|
||||
{
|
||||
cfg: TEST_CFG,
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import { isBunRuntime } from "./client/runtime.js";
|
||||
@@ -71,7 +71,12 @@ async function resolveRuntimeMatrixClient(opts: {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
|
||||
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Matrix runtime client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix runtime client") as CoreConfig;
|
||||
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
|
||||
await loadMatrixSharedClientRuntimeDeps();
|
||||
const authContext = resolveMatrixAuthContext({
|
||||
|
||||
@@ -23,6 +23,21 @@ export const matrixClientResolverMocks: MatrixClientResolverMocks = {
|
||||
resolveMatrixAuthContextMock: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireRuntimeConfig: vi.fn((cfg: unknown) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
return matrixClientResolverMocks.loadConfigMock();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export function createMockMatrixClient(): MatrixClient {
|
||||
return {
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from "../../account-selection.js";
|
||||
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
@@ -556,8 +556,8 @@ function resolveImplicitMatrixAccountId(
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env));
|
||||
}
|
||||
|
||||
export function resolveMatrixAuthContext(params?: {
|
||||
cfg?: CoreConfig;
|
||||
export function resolveMatrixAuthContext(params: {
|
||||
cfg: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
@@ -566,7 +566,7 @@ export function resolveMatrixAuthContext(params?: {
|
||||
accountId: string;
|
||||
resolved: MatrixResolvedConfig;
|
||||
} {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig;
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
|
||||
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
|
||||
@@ -600,7 +600,16 @@ export async function resolveMatrixAuth(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||
if (!params?.cfg) {
|
||||
throw new Error(
|
||||
"Matrix auth requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const accessToken =
|
||||
(await resolveConfiguredMatrixAuthSecretInput({
|
||||
cfg,
|
||||
|
||||
@@ -5,6 +5,8 @@ const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
|
||||
const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn());
|
||||
const createMatrixClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
@@ -106,7 +108,7 @@ describe("resolveSharedMatrixClient", () => {
|
||||
createMatrixClientMock.mockReset();
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ accountId }: { accountId?: string | null } = {}) => ({
|
||||
cfg: undefined,
|
||||
cfg: TEST_CFG,
|
||||
env: undefined,
|
||||
accountId: accountId ?? "default",
|
||||
resolved: {},
|
||||
@@ -122,9 +124,17 @@ describe("resolveSharedMatrixClient", () => {
|
||||
it("keeps account clients isolated when resolves are interleaved", async () => {
|
||||
const { mainClient, opsClient } = primeAccountClientMocks();
|
||||
|
||||
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
|
||||
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
|
||||
const firstMain = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
const firstPoe = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "ops",
|
||||
startClient: false,
|
||||
});
|
||||
const secondMain = await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
|
||||
|
||||
expect(firstMain).toBe(mainClient);
|
||||
expect(firstPoe).toBe(opsClient);
|
||||
@@ -137,8 +147,8 @@ describe("resolveSharedMatrixClient", () => {
|
||||
it("stops only the targeted account client", async () => {
|
||||
const { mainAuth, mainClient, opsClient } = primeAccountClientMocks();
|
||||
|
||||
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
|
||||
await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main", startClient: false });
|
||||
await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "ops", startClient: false });
|
||||
|
||||
stopSharedClientForAccount(mainAuth);
|
||||
|
||||
@@ -160,9 +170,17 @@ describe("resolveSharedMatrixClient", () => {
|
||||
.mockResolvedValueOnce(firstMainClient)
|
||||
.mockResolvedValueOnce(secondMainClient);
|
||||
|
||||
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const first = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
stopSharedClientInstance(first as unknown as import("../sdk.js").MatrixClient);
|
||||
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const second = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
|
||||
expect(first).toBe(firstMainClient);
|
||||
expect(second).toBe(secondMainClient);
|
||||
@@ -175,7 +193,7 @@ describe("resolveSharedMatrixClient", () => {
|
||||
const poeClient = createMockClient("ops");
|
||||
|
||||
resolveMatrixAuthContextMock.mockReturnValue({
|
||||
cfg: undefined,
|
||||
cfg: TEST_CFG,
|
||||
env: undefined,
|
||||
accountId: "ops",
|
||||
resolved: {},
|
||||
@@ -183,13 +201,13 @@ describe("resolveSharedMatrixClient", () => {
|
||||
resolveMatrixAuthMock.mockResolvedValue(poeAuth);
|
||||
createMatrixClientMock.mockResolvedValue(poeClient);
|
||||
|
||||
const first = await resolveSharedMatrixClient({ startClient: false });
|
||||
const second = await resolveSharedMatrixClient({ startClient: false });
|
||||
const first = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false });
|
||||
const second = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false });
|
||||
|
||||
expect(first).toBe(poeClient);
|
||||
expect(second).toBe(poeClient);
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
cfg: undefined,
|
||||
cfg: TEST_CFG,
|
||||
env: undefined,
|
||||
accountId: "ops",
|
||||
});
|
||||
@@ -208,7 +226,11 @@ describe("resolveSharedMatrixClient", () => {
|
||||
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
|
||||
createMatrixClientMock.mockResolvedValue(mainClient);
|
||||
|
||||
const client = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const client = await acquireSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
|
||||
expect(client).toBe(mainClient);
|
||||
expect(mainClient.start).not.toHaveBeenCalled();
|
||||
@@ -224,8 +246,16 @@ describe("resolveSharedMatrixClient", () => {
|
||||
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
|
||||
createMatrixClientMock.mockResolvedValue(mainClient);
|
||||
|
||||
const first = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const second = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const first = await acquireSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
const second = await acquireSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
|
||||
expect(first).toBe(mainClient);
|
||||
expect(second).toBe(mainClient);
|
||||
@@ -254,13 +284,14 @@ describe("resolveSharedMatrixClient", () => {
|
||||
it("lets a later waiter abort while shared startup continues for the owner", async () => {
|
||||
const { mainClient, resolveStartup } = createPendingSharedStartup();
|
||||
|
||||
const ownerPromise = resolveSharedMatrixClient({ accountId: "main" });
|
||||
const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
|
||||
await vi.waitFor(() => {
|
||||
expect(mainClient.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const canceledWaiter = resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
@@ -278,13 +309,14 @@ describe("resolveSharedMatrixClient", () => {
|
||||
it("keeps the shared startup lock while an aborted waiter exits early", async () => {
|
||||
const { mainClient, resolveStartup } = createPendingSharedStartup();
|
||||
|
||||
const ownerPromise = resolveSharedMatrixClient({ accountId: "main" });
|
||||
const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
|
||||
await vi.waitFor(() => {
|
||||
expect(mainClient.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortedWaiter = resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
@@ -294,7 +326,7 @@ describe("resolveSharedMatrixClient", () => {
|
||||
name: "AbortError",
|
||||
});
|
||||
|
||||
const followerPromise = resolveSharedMatrixClient({ accountId: "main" });
|
||||
const followerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
|
||||
expect(mainClient.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveStartup();
|
||||
@@ -324,8 +356,16 @@ describe("resolveSharedMatrixClient", () => {
|
||||
resolveMatrixAuthMock.mockResolvedValueOnce(firstAuth).mockResolvedValueOnce(secondAuth);
|
||||
createMatrixClientMock.mockResolvedValueOnce(firstClient).mockResolvedValueOnce(secondClient);
|
||||
|
||||
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const first = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
const second = await resolveSharedMatrixClient({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
});
|
||||
|
||||
expect(first).toBe(firstClient);
|
||||
expect(second).toBe(secondClient);
|
||||
|
||||
@@ -155,13 +155,21 @@ async function resolveSharedMatrixClientState(
|
||||
`Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`,
|
||||
);
|
||||
}
|
||||
const authContext = params.auth
|
||||
? null
|
||||
: resolveMatrixAuthContext({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const authContext = (() => {
|
||||
if (params.auth) {
|
||||
return null;
|
||||
}
|
||||
if (!params.cfg) {
|
||||
throw new Error(
|
||||
"Matrix shared client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
return resolveMatrixAuthContext({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
})();
|
||||
const auth =
|
||||
params.auth ??
|
||||
(await resolveMatrixAuth({
|
||||
|
||||
@@ -356,6 +356,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
needsRoomAliasesForConfig,
|
||||
});
|
||||
threadBindingManager = await createMatrixThreadBindingManager({
|
||||
cfg,
|
||||
accountId: effectiveAccountId,
|
||||
auth,
|
||||
client,
|
||||
|
||||
@@ -33,6 +33,16 @@ const resolveMarkdownTableModeMock = vi.fn(() => "code");
|
||||
const convertMarkdownTablesMock = vi.fn((text: string) => text);
|
||||
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./outbound-media-runtime.js", () => ({
|
||||
loadOutboundMediaFromUrl: loadOutboundMediaFromUrlMock,
|
||||
}));
|
||||
@@ -179,6 +189,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
@@ -202,6 +213,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
@@ -246,6 +258,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
@@ -279,6 +292,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "voice caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/clip.mp3",
|
||||
audioAsVoice: true,
|
||||
replyToId: "$reply",
|
||||
@@ -310,6 +324,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "voice caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/clip.wav",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
@@ -334,6 +349,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
@@ -401,6 +417,7 @@ describe("sendMessageMatrix media", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
||||
});
|
||||
@@ -426,6 +443,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -439,6 +457,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello @alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -455,6 +474,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello @alice", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -470,6 +490,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "\\@alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -485,6 +506,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "\\@room please review", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -497,6 +519,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "@room please review", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -509,6 +532,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption @alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
@@ -528,6 +552,7 @@ describe("sendMessageMatrix mentions", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
mediaUrl: "file:///tmp/room.png",
|
||||
});
|
||||
|
||||
@@ -552,6 +577,7 @@ describe("sendMessageMatrix threads", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello thread", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
threadId: "$thread",
|
||||
});
|
||||
|
||||
@@ -575,6 +601,7 @@ describe("sendMessageMatrix threads", () => {
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "hello", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
@@ -593,6 +620,7 @@ describe("sendMessageMatrix threads", () => {
|
||||
|
||||
const result = await sendMessageMatrix("room:!room:example", "ignored", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
@@ -618,6 +646,7 @@ describe("sendSingleTextMessageMatrix", () => {
|
||||
await expect(
|
||||
sendSingleTextMessageMatrix("room:!room:example", "1234", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
}),
|
||||
).rejects.toThrow("Matrix single-message text exceeds limit");
|
||||
|
||||
@@ -629,6 +658,7 @@ describe("sendSingleTextMessageMatrix", () => {
|
||||
|
||||
await sendSingleTextMessageMatrix("room:!room:example", "@room hi @alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
msgtype: "m.notice",
|
||||
includeMentions: false,
|
||||
});
|
||||
@@ -648,6 +678,7 @@ describe("sendSingleTextMessageMatrix", () => {
|
||||
|
||||
await sendSingleTextMessageMatrix("room:!room:example", "done", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
|
||||
});
|
||||
|
||||
@@ -679,6 +710,7 @@ describe("editMessageMatrix mentions", () => {
|
||||
"hello @alice:example.org and @bob:example.org",
|
||||
{
|
||||
client,
|
||||
cfg: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -700,6 +732,7 @@ describe("editMessageMatrix mentions", () => {
|
||||
|
||||
await editMessageMatrix("room:!room:example", "$original", "hello again @alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -722,6 +755,7 @@ describe("editMessageMatrix mentions", () => {
|
||||
|
||||
await editMessageMatrix("room:!room:example", "$original", "@alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
|
||||
@@ -743,6 +777,7 @@ describe("editMessageMatrix mentions", () => {
|
||||
|
||||
await editMessageMatrix("room:!room:example", "$original", "@room hi @alice:example.org", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
msgtype: "m.notice",
|
||||
includeMentions: false,
|
||||
});
|
||||
@@ -772,6 +807,7 @@ describe("editMessageMatrix mentions", () => {
|
||||
|
||||
await editMessageMatrix("room:!room:example", "$original", "done", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
|
||||
});
|
||||
|
||||
@@ -801,6 +837,7 @@ describe("sendPollMatrix mentions", () => {
|
||||
},
|
||||
{
|
||||
client,
|
||||
cfg: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -841,6 +878,7 @@ describe("voteMatrixPoll", () => {
|
||||
|
||||
const result = await voteMatrixPoll("room:!room:example", "$poll", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
optionIndex: 2,
|
||||
});
|
||||
|
||||
@@ -877,6 +915,7 @@ describe("voteMatrixPoll", () => {
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example", "$poll", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
optionIndex: 2,
|
||||
}),
|
||||
).rejects.toThrow("out of range");
|
||||
@@ -901,6 +940,7 @@ describe("voteMatrixPoll", () => {
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example", "$poll", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
optionIndexes: [1, 2],
|
||||
}),
|
||||
).rejects.toThrow("at most 1 selection");
|
||||
@@ -916,6 +956,7 @@ describe("voteMatrixPoll", () => {
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example", "$poll", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
optionIndex: 1,
|
||||
}),
|
||||
).rejects.toThrow("is not a Matrix poll start event");
|
||||
@@ -938,6 +979,7 @@ describe("voteMatrixPoll", () => {
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example", "$poll", {
|
||||
client,
|
||||
cfg: {} as never,
|
||||
optionIndex: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import type { PollInput } from "../runtime-api.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
@@ -135,13 +136,13 @@ async function resolvePreviousEditMentions(params: {
|
||||
export function prepareMatrixSingleText(
|
||||
text: string,
|
||||
opts: {
|
||||
cfg?: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
} = {},
|
||||
},
|
||||
): MatrixPreparedSingleText {
|
||||
const trimmedText = text.trim();
|
||||
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text preparation") as CoreConfig;
|
||||
const tableMode =
|
||||
opts.tableMode ??
|
||||
getCore().channel.text.resolveMarkdownTableMode({
|
||||
@@ -165,13 +166,13 @@ export function prepareMatrixSingleText(
|
||||
export function chunkMatrixText(
|
||||
text: string,
|
||||
opts: {
|
||||
cfg?: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
} = {},
|
||||
},
|
||||
): MatrixPreparedChunkedText {
|
||||
const preparedText = prepareMatrixSingleText(text, opts);
|
||||
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text chunking") as CoreConfig;
|
||||
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
|
||||
return {
|
||||
...preparedText,
|
||||
@@ -186,7 +187,7 @@ export function chunkMatrixText(
|
||||
export async function sendMessageMatrix(
|
||||
to: string,
|
||||
message: string | undefined,
|
||||
opts: MatrixSendOpts = {},
|
||||
opts: MatrixSendOpts,
|
||||
): Promise<MatrixSendResult> {
|
||||
const trimmedMessage = message?.trim() ?? "";
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
@@ -201,7 +202,7 @@ export async function sendMessageMatrix(
|
||||
},
|
||||
async (client) => {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix send") as CoreConfig;
|
||||
const { chunks } = chunkMatrixText(trimmedMessage, {
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -330,7 +331,7 @@ export async function sendMessageMatrix(
|
||||
export async function sendPollMatrix(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: MatrixSendOpts = {},
|
||||
opts: MatrixSendOpts,
|
||||
): Promise<{ eventId: string; roomId: string }> {
|
||||
if (!poll.question?.trim()) {
|
||||
throw new Error("Matrix poll requires a question");
|
||||
@@ -416,7 +417,7 @@ export async function sendSingleTextMessageMatrix(
|
||||
text: string,
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
accountId?: string;
|
||||
@@ -425,7 +426,7 @@ export async function sendSingleTextMessageMatrix(
|
||||
extraContent?: MatrixExtraContentFields;
|
||||
/** When true, marks the message as a live/streaming update (MSC4357). */
|
||||
live?: boolean;
|
||||
} = {},
|
||||
},
|
||||
): Promise<MatrixSendResult> {
|
||||
const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } =
|
||||
prepareMatrixSingleText(text, {
|
||||
@@ -502,7 +503,7 @@ export async function editMessageMatrix(
|
||||
newText: string,
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
threadId?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
@@ -511,7 +512,7 @@ export async function editMessageMatrix(
|
||||
extraContent?: MatrixExtraContentFields;
|
||||
/** When true, marks the edit as a live/streaming update (MSC4357). */
|
||||
live?: boolean;
|
||||
} = {},
|
||||
},
|
||||
): Promise<string> {
|
||||
return await withResolvedMatrixSendClient(
|
||||
{
|
||||
@@ -522,7 +523,7 @@ export async function editMessageMatrix(
|
||||
},
|
||||
async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const cfg = opts.cfg ?? getCore().config.loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix message edit") as CoreConfig;
|
||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
|
||||
@@ -16,6 +16,8 @@ const {
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
|
||||
}));
|
||||
@@ -56,7 +58,10 @@ describe("matrix send client helpers", () => {
|
||||
it("stops one-off shared clients when no active monitor client is registered", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok");
|
||||
const result = await withResolvedMatrixSendClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
await expectOneOffSharedMatrixClient({
|
||||
prepareForOneOffCalls: 0,
|
||||
@@ -70,10 +75,13 @@ describe("matrix send client helpers", () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await withResolvedMatrixSendClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
const result = await withResolvedMatrixSendClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
|
||||
@@ -89,7 +97,7 @@ describe("matrix send client helpers", () => {
|
||||
accountId: "ops",
|
||||
resolved: {},
|
||||
});
|
||||
await withResolvedMatrixSendClient({}, async () => {});
|
||||
await withResolvedMatrixSendClient({ cfg: TEST_CFG }, async () => {});
|
||||
|
||||
await expectOneOffSharedMatrixClient({
|
||||
accountId: "ops",
|
||||
@@ -121,7 +129,7 @@ describe("matrix send client helpers", () => {
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedMatrixSendClient({ accountId: "default" }, async () => {
|
||||
withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
@@ -133,7 +141,7 @@ describe("matrix send client helpers", () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok");
|
||||
await withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(sharedClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
@@ -141,7 +149,7 @@ describe("matrix send client helpers", () => {
|
||||
|
||||
it("keeps one-off control clients lightweight when no active monitor client is registered", async () => {
|
||||
const result = await withResolvedMatrixControlClient(
|
||||
{ accountId: "default" },
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
@@ -158,7 +166,7 @@ describe("matrix send client helpers", () => {
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await withResolvedMatrixControlClient(
|
||||
{ accountId: "default" },
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAccountConfig } from "../account-config.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
type MatrixSendClientRuntime = Pick<
|
||||
typeof import("../client-bootstrap.js"),
|
||||
"withResolvedRuntimeMatrixClient"
|
||||
@@ -21,7 +19,12 @@ export function resolveMediaMaxBytes(
|
||||
accountId?: string | null,
|
||||
cfg?: CoreConfig,
|
||||
): number | undefined {
|
||||
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
|
||||
if (!cfg) {
|
||||
throw new Error(
|
||||
"Matrix media limits requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const resolvedCfg = requireRuntimeConfig(cfg, "Matrix media limits") as CoreConfig;
|
||||
const matrixCfg = resolveMatrixAccountConfig({ cfg: resolvedCfg, accountId });
|
||||
const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined;
|
||||
if (typeof mediaMaxMb === "number") {
|
||||
|
||||
@@ -89,8 +89,8 @@ export type MatrixSendResult = {
|
||||
};
|
||||
|
||||
export type MatrixSendOpts = {
|
||||
cfg: CoreConfig;
|
||||
client?: import("../sdk.js").MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("matrix thread bindings", () => {
|
||||
} = {},
|
||||
) {
|
||||
return createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId,
|
||||
auth: params.auth ?? auth,
|
||||
client: matrixClient,
|
||||
@@ -170,6 +171,7 @@ describe("matrix thread bindings", () => {
|
||||
|
||||
it("creates child Matrix thread bindings from a top-level room context", async () => {
|
||||
await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId,
|
||||
auth,
|
||||
client: matrixClient,
|
||||
@@ -193,6 +195,7 @@ describe("matrix thread bindings", () => {
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro root", {
|
||||
cfg: {},
|
||||
client: {},
|
||||
accountId: "ops",
|
||||
});
|
||||
@@ -214,6 +217,7 @@ describe("matrix thread bindings", () => {
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro thread", {
|
||||
cfg: {},
|
||||
client: {},
|
||||
accountId: "ops",
|
||||
threadId: "$thread",
|
||||
@@ -236,6 +240,7 @@ describe("matrix thread bindings", () => {
|
||||
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
|
||||
try {
|
||||
await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
auth,
|
||||
client: {} as never,
|
||||
@@ -280,6 +285,7 @@ describe("matrix thread bindings", () => {
|
||||
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
|
||||
try {
|
||||
await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
auth,
|
||||
client: {} as never,
|
||||
@@ -333,6 +339,7 @@ describe("matrix thread bindings", () => {
|
||||
const logVerboseMessage = vi.fn();
|
||||
try {
|
||||
await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
auth,
|
||||
client: {} as never,
|
||||
@@ -387,6 +394,7 @@ describe("matrix thread bindings", () => {
|
||||
|
||||
it("sends threaded farewell messages when bindings are unbound", async () => {
|
||||
await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
auth,
|
||||
client: {} as never,
|
||||
@@ -420,6 +428,7 @@ describe("matrix thread bindings", () => {
|
||||
"room:!room:example",
|
||||
expect.stringContaining("Session ended automatically"),
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
threadId: "$thread",
|
||||
}),
|
||||
@@ -569,6 +578,7 @@ describe("matrix thread bindings", () => {
|
||||
vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));
|
||||
try {
|
||||
const manager = await createMatrixThreadBindingManager({
|
||||
cfg: {},
|
||||
accountId: "ops",
|
||||
auth,
|
||||
client: {} as never,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -146,6 +147,7 @@ function buildMatrixBindingIntroText(params: {
|
||||
}
|
||||
|
||||
async function sendBindingMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
client: MatrixClient;
|
||||
accountId: string;
|
||||
roomId: string;
|
||||
@@ -157,6 +159,7 @@ async function sendBindingMessage(params: {
|
||||
return null;
|
||||
}
|
||||
const result = await sendMessageMatrix(`room:${params.roomId}`, trimmed, {
|
||||
cfg: params.cfg,
|
||||
client: params.client,
|
||||
accountId: params.accountId,
|
||||
...(params.threadId ? { threadId: params.threadId } : {}),
|
||||
@@ -165,6 +168,7 @@ async function sendBindingMessage(params: {
|
||||
}
|
||||
|
||||
async function sendFarewellMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
client: MatrixClient;
|
||||
accountId: string;
|
||||
record: MatrixThreadBindingRecord;
|
||||
@@ -185,6 +189,7 @@ async function sendFarewellMessage(params: {
|
||||
maxAgeMs,
|
||||
});
|
||||
await sendBindingMessage({
|
||||
cfg: params.cfg,
|
||||
client: params.client,
|
||||
accountId: params.accountId,
|
||||
roomId,
|
||||
@@ -198,6 +203,7 @@ async function sendFarewellMessage(params: {
|
||||
}
|
||||
|
||||
export async function createMatrixThreadBindingManager(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
auth: MatrixAuth;
|
||||
client: MatrixClient;
|
||||
@@ -387,6 +393,7 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
await Promise.all(
|
||||
removed.map(async (record) => {
|
||||
await sendFarewellMessage({
|
||||
cfg: params.cfg,
|
||||
client: params.client,
|
||||
accountId: params.accountId,
|
||||
record,
|
||||
@@ -429,6 +436,7 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
if (input.placement === "child") {
|
||||
const roomId = parentConversationId || conversationId;
|
||||
const rootEventId = await sendBindingMessage({
|
||||
cfg: params.cfg,
|
||||
client: params.client,
|
||||
accountId: params.accountId,
|
||||
roomId,
|
||||
@@ -468,6 +476,7 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
? boundConversationId
|
||||
: undefined;
|
||||
await sendBindingMessage({
|
||||
cfg: params.cfg,
|
||||
client: params.client,
|
||||
accountId: params.accountId,
|
||||
roomId,
|
||||
|
||||
@@ -196,6 +196,7 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, message, {
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
replyToId,
|
||||
buttons: presentation
|
||||
|
||||
@@ -16,7 +16,7 @@ type SendMattermostMessage = (
|
||||
to: string,
|
||||
text: string,
|
||||
opts: {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectProvidedCfgSkipsRuntimeLoad,
|
||||
expectRuntimeCfgFallback,
|
||||
} from "../../../../test/helpers/plugins/send-config.js";
|
||||
import { expectProvidedCfgSkipsRuntimeLoad } from "../../../../test/helpers/plugins/send-config.js";
|
||||
|
||||
let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget;
|
||||
let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost;
|
||||
@@ -12,6 +9,8 @@ type SendMessageMattermostOptions = NonNullable<
|
||||
Parameters<typeof import("./send.js").sendMessageMattermost>[2]
|
||||
>;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
loadOutboundMediaFromUrl: vi.fn(),
|
||||
@@ -40,6 +39,12 @@ vi.mock("../../runtime-api.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
|
||||
requireRuntimeConfig: (cfg: unknown) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
throw new Error("Mattermost send requires a resolved runtime config");
|
||||
},
|
||||
resolveMarkdownTableMode: vi.fn(() => "off"),
|
||||
}));
|
||||
|
||||
@@ -178,30 +183,12 @@ describe("sendMessageMattermost", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime loadConfig when cfg is omitted", async () => {
|
||||
const runtimeCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "runtime-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "runtime-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello");
|
||||
|
||||
expectRuntimeCfgFallback({
|
||||
loadConfig: mockState.loadConfig,
|
||||
resolveAccount: mockState.resolveMattermostAccount,
|
||||
cfg: runtimeCfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
it("fails hard when cfg is omitted", async () => {
|
||||
await expect(
|
||||
sendMessageMattermost("channel:town-square", "hello", undefined as never),
|
||||
).rejects.toThrow("Mattermost send requires a resolved runtime config");
|
||||
expect(mockState.loadConfig).not.toHaveBeenCalled();
|
||||
expect(mockState.resolveMattermostAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
@@ -249,6 +236,7 @@ describe("sendMessageMattermost", () => {
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
@@ -278,6 +266,7 @@ describe("sendMessageMattermost", () => {
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "Pick a model", {
|
||||
cfg: TEST_CFG,
|
||||
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
|
||||
});
|
||||
|
||||
@@ -319,6 +308,7 @@ describe("sendMessageMattermost", () => {
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(userId, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
@@ -357,6 +347,7 @@ describe("sendMessageMattermost", () => {
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(channelId, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
@@ -484,7 +475,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res = await sendMessageMattermost(userId, "hello");
|
||||
const res = await sendMessageMattermost(userId, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
@@ -501,7 +492,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
const err = new Error("Mattermost API 404: user not found");
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
|
||||
|
||||
const res = await sendMessageMattermost(channelId, "hello");
|
||||
const res = await sendMessageMattermost(channelId, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
@@ -521,7 +512,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
|
||||
|
||||
const res1 = await sendMessageMattermost(userId, "first");
|
||||
const res1 = await sendMessageMattermost(userId, "first", { cfg: TEST_CFG });
|
||||
expect(res1.channelId).toBe(userId);
|
||||
|
||||
// Second call with a different token (new cache key) → retries user lookup
|
||||
@@ -533,7 +524,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res2 = await sendMessageMattermost(userId, "second");
|
||||
const res2 = await sendMessageMattermost(userId, "second", { cfg: TEST_CFG });
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(res2.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
@@ -544,7 +535,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
|
||||
const res = await sendMessageMattermost(`user:${userId}`, "hello");
|
||||
const res = await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
@@ -556,7 +547,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
|
||||
|
||||
const res = await sendMessageMattermost(`channel:${chanId}`, "hello");
|
||||
const res = await sendMessageMattermost(`channel:${chanId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
@@ -578,6 +569,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
dmRetryOptions: retryOptions,
|
||||
});
|
||||
|
||||
@@ -605,7 +597,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello");
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", { cfg: TEST_CFG });
|
||||
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
@@ -640,6 +632,7 @@ describe("sendMessageMattermost user-first resolution", () => {
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
cfg: TEST_CFG,
|
||||
dmRetryOptions: overrideOptions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
convertMarkdownTables,
|
||||
@@ -30,7 +30,7 @@ import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js"
|
||||
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
@@ -315,11 +315,16 @@ type MattermostSendContext = {
|
||||
|
||||
async function resolveMattermostSendContext(
|
||||
to: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
opts: MattermostSendOpts,
|
||||
): Promise<MattermostSendContext> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = opts.cfg ?? core.config.loadConfig();
|
||||
if (!opts?.cfg) {
|
||||
throw new Error(
|
||||
"Mattermost send requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Mattermost send");
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -382,7 +387,7 @@ async function resolveMattermostSendContext(
|
||||
|
||||
export async function resolveMattermostSendChannelId(
|
||||
to: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
opts: MattermostSendOpts,
|
||||
): Promise<string> {
|
||||
return (await resolveMattermostSendContext(to, opts)).channelId;
|
||||
}
|
||||
@@ -390,7 +395,7 @@ export async function resolveMattermostSendChannelId(
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
opts: MattermostSendOpts,
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
|
||||
@@ -31,16 +31,18 @@ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
|
||||
const CHANNEL_ID = "nextcloud-talk" as const;
|
||||
|
||||
async function deliverNextcloudTalkReply(params: {
|
||||
cfg: CoreConfig;
|
||||
payload: OutboundReplyPayload;
|
||||
roomToken: string;
|
||||
accountId: string;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { payload, roomToken, accountId, statusSink } = params;
|
||||
const { cfg, payload, roomToken, accountId, statusSink } = params;
|
||||
await deliverFormattedTextWithAttachments({
|
||||
payload,
|
||||
send: async ({ text, replyToId }) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg,
|
||||
accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
@@ -177,7 +179,10 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
senderIdLine: `Your Nextcloud user id: ${senderId}`,
|
||||
meta: { name: senderName || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId });
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
@@ -291,6 +296,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
core,
|
||||
deliver: async (payload) => {
|
||||
await deliverNextcloudTalkReply({
|
||||
cfg: config,
|
||||
payload,
|
||||
roomToken,
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSendCfgThreadingRuntime,
|
||||
expectProvidedCfgSkipsRuntimeLoad,
|
||||
expectRuntimeCfgFallback,
|
||||
} from "../../../test/helpers/plugins/send-config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -25,6 +24,12 @@ vi.mock("./send.runtime.js", () => {
|
||||
fetchWithSsrFGuard: hoisted.mockFetchGuard,
|
||||
generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
|
||||
getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted),
|
||||
requireRuntimeConfig: (cfg: unknown, context: string) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
throw new Error(`${context} requires a resolved runtime config`);
|
||||
},
|
||||
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
|
||||
@@ -133,21 +138,16 @@ describe("nextcloud-talk send cfg threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => {
|
||||
const runtimeCfg = { source: "runtime" } as const;
|
||||
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
it("fails hard for sendReaction when cfg is omitted", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
accountId: "default",
|
||||
});
|
||||
await expect(
|
||||
sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
accountId: "default",
|
||||
} as never),
|
||||
).rejects.toThrow("Nextcloud Talk send requires a resolved runtime config");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expectRuntimeCfgFallback({
|
||||
loadConfig: hoisted.loadConfig,
|
||||
resolveAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
cfg: runtimeCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveNextcloudTalkAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
export { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchWithSsrFGuard,
|
||||
generateNextcloudTalkSignature,
|
||||
getNextcloudTalkRuntime,
|
||||
requireRuntimeConfig,
|
||||
resolveMarkdownTableMode,
|
||||
resolveNextcloudTalkAccount,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
@@ -11,12 +12,12 @@ import {
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
type NextcloudTalkSendOpts = {
|
||||
cfg: CoreConfig;
|
||||
baseUrl?: string;
|
||||
secret?: string;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
verbose?: boolean;
|
||||
cfg?: CoreConfig;
|
||||
};
|
||||
|
||||
function resolveCredentials(
|
||||
@@ -54,7 +55,7 @@ function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): {
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
} {
|
||||
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -83,7 +84,7 @@ function recordNextcloudTalkOutboundActivity(accountId: string): void {
|
||||
export async function sendMessageNextcloudTalk(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: NextcloudTalkSendOpts = {},
|
||||
opts: NextcloudTalkSendOpts,
|
||||
): Promise<NextcloudTalkSendResult> {
|
||||
const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
||||
const roomToken = normalizeRoomToken(to);
|
||||
@@ -192,7 +193,7 @@ export async function sendReactionNextcloudTalk(
|
||||
roomToken: string,
|
||||
messageId: string,
|
||||
reaction: string,
|
||||
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
||||
opts: Omit<NextcloudTalkSendOpts, "replyTo">,
|
||||
): Promise<{ ok: true }> {
|
||||
const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
||||
const normalizedToken = normalizeRoomToken(roomToken);
|
||||
|
||||
@@ -348,8 +348,12 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
idLabel: "signalNumber",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i),
|
||||
notify: async ({ id, message }) => {
|
||||
await (await loadSignalSendRuntime()).sendMessageSignal(id, message);
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
await (
|
||||
await loadSignalSendRuntime()
|
||||
).sendMessageSignal(id, message, {
|
||||
cfg,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -304,6 +304,7 @@ async function fetchAttachment(params: {
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
@@ -324,6 +325,7 @@ async function deliverReplies(params: {
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
@@ -332,6 +334,7 @@ async function deliverReplies(params: {
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
await sendMessageSignal(target, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
@@ -465,7 +468,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
fetchAttachment,
|
||||
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||
deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
|
||||
@@ -139,11 +139,26 @@ describe("signal createSignalEventHandler inbound context", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendTypingMock).toHaveBeenCalledWith("+15550001111", expect.any(Object));
|
||||
expect(sendTypingMock).toHaveBeenCalledWith(
|
||||
"+15550001111",
|
||||
expect.objectContaining({
|
||||
cfg: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
signal: expect.objectContaining({ dmPolicy: "open" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(sendReadReceiptMock).toHaveBeenCalledWith(
|
||||
"signal:+15550001111",
|
||||
1700000000000,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
cfg: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
signal: expect.objectContaining({ dmPolicy: "open" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
return;
|
||||
}
|
||||
await sendTypingSignal(ctxPayload.To, {
|
||||
cfg: deps.cfg,
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
@@ -306,6 +307,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deps.deliverReplies({
|
||||
cfg: deps.cfg,
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
baseUrl: deps.baseUrl,
|
||||
@@ -601,6 +603,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
accountId: deps.accountId,
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageSignal(`signal:${senderRecipient}`, text, {
|
||||
cfg: deps.cfg,
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
@@ -831,6 +834,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) {
|
||||
try {
|
||||
await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, {
|
||||
cfg: deps.cfg,
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
|
||||
@@ -105,6 +105,7 @@ export type SignalEventHandlerDeps = {
|
||||
maxBytes: number;
|
||||
}) => Promise<{ path: string; contentType?: string } | null>;
|
||||
deliverReplies: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
|
||||
@@ -29,6 +29,16 @@ vi.mock("./client.js", () => ({
|
||||
let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal;
|
||||
let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal;
|
||||
|
||||
const SIGNAL_TEST_CFG = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("sendReactionSignal", () => {
|
||||
beforeAll(async () => {
|
||||
({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js"));
|
||||
@@ -39,7 +49,9 @@ describe("sendReactionSignal", () => {
|
||||
});
|
||||
|
||||
it("uses recipients array and targetAuthor for uuid dms", async () => {
|
||||
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
|
||||
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
});
|
||||
|
||||
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
|
||||
@@ -52,6 +64,7 @@ describe("sendReactionSignal", () => {
|
||||
|
||||
it("uses groupIds array and maps targetAuthorUuid", async () => {
|
||||
await sendReactionSignal("", 123, "✅", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
groupId: "group-id",
|
||||
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
@@ -63,7 +76,7 @@ describe("sendReactionSignal", () => {
|
||||
});
|
||||
|
||||
it("defaults targetAuthor to recipient for removals", async () => {
|
||||
await removeReactionSignal("+15551230000", 456, "❌");
|
||||
await removeReactionSignal("+15551230000", 456, "❌", { cfg: SIGNAL_TEST_CFG });
|
||||
|
||||
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(params.recipients).toEqual(["+15551230000"]);
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Signal reactions via signal-cli JSON-RPC API
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalReactionOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
@@ -31,15 +31,6 @@ type SignalReactionErrorMessages = {
|
||||
missingTargetAuthor: string;
|
||||
};
|
||||
|
||||
let signalConfigRuntimePromise:
|
||||
| Promise<typeof import("openclaw/plugin-sdk/config-runtime")>
|
||||
| undefined;
|
||||
|
||||
async function loadSignalConfigRuntime() {
|
||||
signalConfigRuntimePromise ??= import("openclaw/plugin-sdk/config-runtime");
|
||||
return await signalConfigRuntimePromise;
|
||||
}
|
||||
|
||||
function normalizeSignalId(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -86,7 +77,7 @@ async function sendReactionSignalCore(params: {
|
||||
opts: SignalReactionOpts;
|
||||
errors: SignalReactionErrorMessages;
|
||||
}): Promise<SignalReactionResult> {
|
||||
const cfg = params.opts.cfg ?? (await loadSignalConfigRuntime()).loadConfig();
|
||||
const cfg = requireRuntimeConfig(params.opts.cfg, "Signal reactions");
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: params.opts.accountId,
|
||||
@@ -153,7 +144,7 @@ export async function sendReactionSignal(
|
||||
recipient: string,
|
||||
targetTimestamp: number,
|
||||
emoji: string,
|
||||
opts: SignalReactionOpts = {},
|
||||
opts: SignalReactionOpts,
|
||||
): Promise<SignalReactionResult> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
@@ -181,7 +172,7 @@ export async function removeReactionSignal(
|
||||
recipient: string,
|
||||
targetTimestamp: number,
|
||||
emoji: string,
|
||||
opts: SignalReactionOpts = {},
|
||||
opts: SignalReactionOpts,
|
||||
): Promise<SignalReactionResult> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -9,7 +9,7 @@ import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
@@ -43,13 +43,16 @@ type SignalTarget =
|
||||
| { type: "group"; groupId: string }
|
||||
| { type: "username"; username: string };
|
||||
|
||||
async function resolveSignalRpcAccountInfo(
|
||||
opts: Pick<SignalSendOpts, "cfg" | "baseUrl" | "account" | "accountId">,
|
||||
) {
|
||||
async function resolveSignalRpcAccountInfo(opts: SignalRpcOpts) {
|
||||
if (opts.baseUrl?.trim() && opts.account?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Signal RPC account resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Signal RPC account resolution");
|
||||
return resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -121,9 +124,9 @@ function buildTargetParams(
|
||||
export async function sendMessageSignal(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SignalSendOpts = {},
|
||||
opts: SignalSendOpts,
|
||||
): Promise<SignalSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Signal send");
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
@@ -218,7 +221,7 @@ export async function sendMessageSignal(
|
||||
|
||||
export async function sendTypingSignal(
|
||||
to: string,
|
||||
opts: SignalRpcOpts & { stop?: boolean } = {},
|
||||
opts: SignalRpcOpts & { stop?: boolean },
|
||||
): Promise<boolean> {
|
||||
const accountInfo = await resolveSignalRpcAccountInfo(opts);
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
@@ -246,7 +249,7 @@ export async function sendTypingSignal(
|
||||
export async function sendReadReceiptSignal(
|
||||
to: string,
|
||||
targetTimestamp: number,
|
||||
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
|
||||
opts: SignalRpcOpts & { type?: SignalReceiptType },
|
||||
): Promise<boolean> {
|
||||
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -109,19 +109,14 @@ describe("resolveSlackAccount allowFrom precedence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
|
||||
// The static `SlackAccountConfig.botToken` type is `string` because it
|
||||
// models the post-resolution shape, but the runtime cfg snapshot can still
|
||||
// hold an unresolved `SecretRef` object for inactive channel targets (per
|
||||
// the inspect/strict separation in #66818). Cast via `unknown` so the test
|
||||
// can construct that runtime-only shape without weakening the production
|
||||
// type. See #68237.
|
||||
describe("resolveSlackAccount active secret surfaces", () => {
|
||||
const secretRef = { source: "exec", provider: "default", id: "slack_token" } as const;
|
||||
const cfgWithUnresolvedBotTokenRef = {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
|
||||
botToken: secretRef,
|
||||
allowFrom: ["U999"],
|
||||
},
|
||||
},
|
||||
@@ -129,7 +124,7 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
it("throws by default when the snapshot still holds an unresolved SecretRef botToken", () => {
|
||||
it("throws when an enabled account still has an unresolved active bot token SecretRef", () => {
|
||||
expect(() =>
|
||||
resolveSlackAccount({
|
||||
cfg: cfgWithUnresolvedBotTokenRef,
|
||||
@@ -138,100 +133,83 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
|
||||
).toThrowError(/channels\.slack\.accounts\.default\.botToken/);
|
||||
});
|
||||
|
||||
it("returns undefined credentials without throwing when tolerateUnresolvedSecrets is set", () => {
|
||||
const resolved = resolveSlackAccount({
|
||||
cfg: cfgWithUnresolvedBotTokenRef,
|
||||
accountId: "default",
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBeUndefined();
|
||||
expect(resolved.botTokenSource).toBe("none");
|
||||
// Surrounding account info still resolves so callers with an explicit
|
||||
// override (for example sendMessageSlack receiving opts.token) can keep
|
||||
// operating.
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.config.allowFrom).toEqual(["U999"]);
|
||||
});
|
||||
|
||||
it("still returns resolved string credentials in tolerant mode", () => {
|
||||
it("does not read credentials for disabled accounts", () => {
|
||||
const resolved = resolveSlackAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: { botToken: "xoxb-resolved", appToken: "xapp-resolved" },
|
||||
default: {
|
||||
enabled: false,
|
||||
botToken: secretRef,
|
||||
appToken: secretRef,
|
||||
userToken: secretRef,
|
||||
allowFrom: ["U999"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBeUndefined();
|
||||
expect(resolved.botTokenSource).toBe("none");
|
||||
expect(resolved.appToken).toBeUndefined();
|
||||
expect(resolved.appTokenSource).toBe("none");
|
||||
expect(resolved.userToken).toBeUndefined();
|
||||
expect(resolved.userTokenSource).toBe("none");
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.config.allowFrom).toEqual(["U999"]);
|
||||
});
|
||||
|
||||
it("does not read socket-only app token for HTTP mode accounts", () => {
|
||||
const resolved = resolveSlackAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
mode: "http",
|
||||
botToken: "xoxb-resolved",
|
||||
appToken: secretRef,
|
||||
signingSecret: "signing-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
accountId: "default",
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBe("xoxb-resolved");
|
||||
expect(resolved.botTokenSource).toBe("config");
|
||||
expect(resolved.appToken).toBe("xapp-resolved");
|
||||
expect(resolved.appTokenSource).toBe("config");
|
||||
expect(resolved.appToken).toBeUndefined();
|
||||
expect(resolved.appTokenSource).toBe("none");
|
||||
});
|
||||
|
||||
it("does not silently fall back to SLACK_*_TOKEN env vars in tolerant mode when all credentials are configured as SecretRef (credential confusion guard)", () => {
|
||||
// Each credential is configured as a SecretRef. In tolerant mode none of
|
||||
// them resolves, so per-credential env gating must block all three env
|
||||
// vars; otherwise a stray `SLACK_*_TOKEN` would silently impersonate the
|
||||
// operator-configured account (CWE-287 credential confusion).
|
||||
const cfgAllSecretRefs = {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
|
||||
appToken: { source: "exec", provider: "default", id: "slack_app_token" },
|
||||
userToken: { source: "exec", provider: "default", id: "slack_user_token" },
|
||||
it("throws when a socket-mode account still has an unresolved active app token SecretRef", () => {
|
||||
expect(() =>
|
||||
resolveSlackAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
mode: "socket",
|
||||
botToken: "xoxb-resolved",
|
||||
appToken: secretRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const previousBotToken = process.env.SLACK_BOT_TOKEN;
|
||||
const previousAppToken = process.env.SLACK_APP_TOKEN;
|
||||
const previousUserToken = process.env.SLACK_USER_TOKEN;
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-env-fallback";
|
||||
process.env.SLACK_APP_TOKEN = "xapp-env-fallback";
|
||||
process.env.SLACK_USER_TOKEN = "xoxp-env-fallback";
|
||||
try {
|
||||
const resolved = resolveSlackAccount({
|
||||
cfg: cfgAllSecretRefs,
|
||||
} as unknown as OpenClawConfig,
|
||||
accountId: "default",
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBeUndefined();
|
||||
expect(resolved.botTokenSource).toBe("none");
|
||||
expect(resolved.appToken).toBeUndefined();
|
||||
expect(resolved.appTokenSource).toBe("none");
|
||||
expect(resolved.userToken).toBeUndefined();
|
||||
expect(resolved.userTokenSource).toBe("none");
|
||||
} finally {
|
||||
if (previousBotToken === undefined) {
|
||||
delete process.env.SLACK_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.SLACK_BOT_TOKEN = previousBotToken;
|
||||
}
|
||||
if (previousAppToken === undefined) {
|
||||
delete process.env.SLACK_APP_TOKEN;
|
||||
} else {
|
||||
process.env.SLACK_APP_TOKEN = previousAppToken;
|
||||
}
|
||||
if (previousUserToken === undefined) {
|
||||
delete process.env.SLACK_USER_TOKEN;
|
||||
} else {
|
||||
process.env.SLACK_USER_TOKEN = previousUserToken;
|
||||
}
|
||||
}
|
||||
}),
|
||||
).toThrowError(/channels\.slack\.accounts\.default\.appToken/);
|
||||
});
|
||||
|
||||
it("preserves SLACK_BOT_TOKEN env fallback in tolerant mode when no config token is set (env-only setups)", () => {
|
||||
it("preserves env fallback when no active config token is set", () => {
|
||||
const previousBotToken = process.env.SLACK_BOT_TOKEN;
|
||||
const previousAppToken = process.env.SLACK_APP_TOKEN;
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-env-only";
|
||||
@@ -252,7 +230,6 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBe("xoxb-env-only");
|
||||
@@ -273,36 +250,31 @@ describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks env fallback per-credential: unresolved SecretRef on botToken does not leak SLACK_APP_TOKEN", () => {
|
||||
it("does not use env fallback for inactive credentials", () => {
|
||||
const previousBotToken = process.env.SLACK_BOT_TOKEN;
|
||||
const previousAppToken = process.env.SLACK_APP_TOKEN;
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-env-bot";
|
||||
process.env.SLACK_APP_TOKEN = "xapp-env-app";
|
||||
try {
|
||||
// botToken has an unresolved SecretRef (env fallback should be
|
||||
// blocked), but appToken is unset (env fallback should still fire).
|
||||
// This proves the gating is per-credential, not whole-account.
|
||||
const resolved = resolveSlackAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
},
|
||||
accountId: "default",
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
|
||||
expect(resolved.botToken).toBeUndefined();
|
||||
expect(resolved.botTokenSource).toBe("none");
|
||||
// appToken was never configured → env fallback still fires.
|
||||
expect(resolved.appToken).toBe("xapp-env-app");
|
||||
expect(resolved.appTokenSource).toBe("env");
|
||||
expect(resolved.appToken).toBeUndefined();
|
||||
expect(resolved.appTokenSource).toBe("none");
|
||||
} finally {
|
||||
if (previousBotToken === undefined) {
|
||||
delete process.env.SLACK_BOT_TOKEN;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveMergedAccountConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { isSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
|
||||
import type { SlackAccountConfig } from "./runtime-api.js";
|
||||
@@ -45,28 +44,6 @@ export function mergeSlackAccountConfig(
|
||||
export function resolveSlackAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
/**
|
||||
* When true, account-level credential reads (`botToken`, `appToken`,
|
||||
* `userToken`) silently become `undefined` for unresolved `SecretRef`
|
||||
* inputs instead of throwing. Default is false to preserve the strict
|
||||
* behavior expected by boot-time provider initialization, which must
|
||||
* surface unresolved channel SecretRefs loudly.
|
||||
*
|
||||
* Pass `true` from call sites that already have a separately-resolved
|
||||
* credential override (for example `sendMessageSlack` receives an explicit
|
||||
* `opts.token` derived from the boot-time monitor context) and only need
|
||||
* the rest of the account info (account id, dm policy, channel settings,
|
||||
* etc.). The downstream consumer's existing `if (!token)` guard still
|
||||
* surfaces a clean "missing token" error when no explicit override is
|
||||
* supplied either.
|
||||
*
|
||||
* Without this opt-in, an inactive `channels.slack.accounts.*.botToken`
|
||||
* SecretRef left in the runtime snapshot (per the inspect/strict
|
||||
* separation introduced in #66818) blows up the strict resolver path even
|
||||
* though the actual send already has a valid boot-resolved token. See
|
||||
* #68237.
|
||||
*/
|
||||
tolerateUnresolvedSecrets?: boolean;
|
||||
}): ResolvedSlackAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
|
||||
@@ -75,36 +52,26 @@ export function resolveSlackAccount(params: {
|
||||
const merged = mergeSlackAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
// Per-credential env-var fallback gating: in tolerant mode, only block
|
||||
// the `SLACK_*_TOKEN` env fallback for credentials whose configured value
|
||||
// is an unresolved `SecretRef` object. Otherwise (config field is a
|
||||
// resolved string, or unset entirely) keep the original env fallback so
|
||||
// legitimate env-only setups (no per-account config token, just
|
||||
// `SLACK_BOT_TOKEN` in the process env) keep working. This avoids
|
||||
// credential confusion (CWE-287) on misconfigured deployments where an
|
||||
// unresolved SecretRef would otherwise be silently overridden by a stray
|
||||
// env var, while preserving the env-only contract that callers like
|
||||
// `extensions/slack/src/channel.ts` rely on when omitting `opts.token`.
|
||||
const mode = merged.mode ?? "socket";
|
||||
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const tolerantMode = params.tolerateUnresolvedSecrets === true;
|
||||
const blockBotEnv = tolerantMode && isSecretRef(merged.botToken);
|
||||
const blockAppEnv = tolerantMode && isSecretRef(merged.appToken);
|
||||
const blockUserEnv = tolerantMode && isSecretRef(merged.userToken);
|
||||
const botActive = enabled;
|
||||
const appActive = enabled && mode !== "http";
|
||||
const userActive = enabled;
|
||||
const envBot =
|
||||
baseAllowEnv && !blockBotEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
||||
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
||||
const envApp =
|
||||
baseAllowEnv && !blockAppEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
|
||||
appActive && baseAllowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
|
||||
const envUser =
|
||||
baseAllowEnv && !blockUserEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined;
|
||||
const configBot = tolerantMode
|
||||
? normalizeSecretInputString(merged.botToken)
|
||||
: resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`);
|
||||
const configApp = tolerantMode
|
||||
? normalizeSecretInputString(merged.appToken)
|
||||
: resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`);
|
||||
const configUser = tolerantMode
|
||||
? normalizeSecretInputString(merged.userToken)
|
||||
: resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`);
|
||||
userActive && baseAllowEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined;
|
||||
const configBot = botActive
|
||||
? resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`)
|
||||
: undefined;
|
||||
const configApp = appActive
|
||||
? resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`)
|
||||
: undefined;
|
||||
const configUser = userActive
|
||||
? resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`)
|
||||
: undefined;
|
||||
const botToken = configBot ?? envBot;
|
||||
const appToken = configApp ?? envApp;
|
||||
const userToken = configUser ?? envUser;
|
||||
|
||||
@@ -50,11 +50,16 @@ describe("handleSlackAction", () => {
|
||||
}
|
||||
|
||||
function expectLastSlackSend(content: string, threadTs?: string) {
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", content, {
|
||||
mediaUrl: undefined,
|
||||
threadTs,
|
||||
blocks: undefined,
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith(
|
||||
"channel:C123",
|
||||
content,
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: undefined,
|
||||
threadTs,
|
||||
blocks: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendSecondMessageAndExpectNoThread(params: {
|
||||
@@ -119,7 +124,12 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
|
||||
expect(reactSlackMessage).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
"123.456",
|
||||
"✅",
|
||||
expect.objectContaining({ cfg: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
@@ -132,7 +142,11 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456");
|
||||
expect(removeOwnSlackReactions).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
"123.456",
|
||||
expect.objectContaining({ cfg: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions when remove flag set", async () => {
|
||||
@@ -146,7 +160,12 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅");
|
||||
expect(removeSlackReaction).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
"123.456",
|
||||
"✅",
|
||||
expect.objectContaining({ cfg: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects removes without emoji", async () => {
|
||||
@@ -188,11 +207,16 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1234567890.123456",
|
||||
blocks: undefined,
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"Hello thread",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1234567890.123456",
|
||||
blocks: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a friendly error when downloadFile cannot fetch the attachment", async () => {
|
||||
@@ -289,11 +313,16 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
blocks: expectedBlocks,
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
blocks: expectedBlocks,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -344,12 +373,17 @@ describe("handleSlackAction", () => {
|
||||
slackConfig(),
|
||||
);
|
||||
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("user:U123", "fresh report", {
|
||||
mediaUrl: "/tmp/report.png",
|
||||
threadTs: "111.222",
|
||||
uploadFileName: "report-final.png",
|
||||
uploadTitle: "Report Final",
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"user:U123",
|
||||
"fresh report",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: "/tmp/report.png",
|
||||
threadTs: "111.222",
|
||||
uploadFileName: "report-final.png",
|
||||
uploadTitle: "Report Final",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects blocks combined with mediaUrl", async () => {
|
||||
@@ -389,9 +423,15 @@ describe("handleSlackAction", () => {
|
||||
},
|
||||
slackConfig(),
|
||||
);
|
||||
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
|
||||
blocks: expectedBlocks,
|
||||
});
|
||||
expect(editSlackMessage).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"123.456",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
blocks: expectedBlocks,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires content or blocks for editMessage", async () => {
|
||||
@@ -493,11 +533,16 @@ describe("handleSlackAction", () => {
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
blocks: undefined,
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C999",
|
||||
"Other channel",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
blocks: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit threadTs overrides context threadTs", async () => {
|
||||
@@ -528,11 +573,16 @@ describe("handleSlackAction", () => {
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1111111111.111111",
|
||||
blocks: undefined,
|
||||
});
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"C123",
|
||||
"Bare target",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1111111111.111111",
|
||||
blocks: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to readMessages payloads", async () => {
|
||||
@@ -568,12 +618,16 @@ describe("handleSlackAction", () => {
|
||||
slackConfig(),
|
||||
);
|
||||
|
||||
expect(readSlackMessages).toHaveBeenCalledWith("C1", {
|
||||
threadId: "1712345678.123456",
|
||||
limit: undefined,
|
||||
before: undefined,
|
||||
after: undefined,
|
||||
});
|
||||
expect(readSlackMessages).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
threadId: "1712345678.123456",
|
||||
limit: undefined,
|
||||
before: undefined,
|
||||
after: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to pin payloads", async () => {
|
||||
|
||||
@@ -172,10 +172,8 @@ export async function handleSlackAction(
|
||||
const buildActionOpts = (operation: "read" | "write") => {
|
||||
const token = getTokenForOperation(operation);
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (!accountId && !tokenOverride) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(tokenOverride ? { token: tokenOverride } : {}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Block, KnownBlock, WebClient } from "@slack/web-api";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
|
||||
@@ -11,6 +11,7 @@ import { sendMessageSlack } from "./send.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
export type SlackActionClientOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
client?: WebClient;
|
||||
@@ -41,10 +42,21 @@ export type SlackPin = {
|
||||
file?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string, accountId?: string) {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined);
|
||||
function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfig): string {
|
||||
if (explicit?.trim()) {
|
||||
const token = resolveSlackBotToken(explicit);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
if (!cfg) {
|
||||
throw new Error(
|
||||
"Slack actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const resolvedCfg = requireRuntimeConfig(cfg, "Slack actions");
|
||||
const account = resolveSlackAccount({ cfg: resolvedCfg, accountId });
|
||||
const token = resolveSlackBotToken(account.botToken ?? undefined);
|
||||
if (!token) {
|
||||
logVerbose(
|
||||
`slack actions: missing bot token for account=${account.accountId} explicit=${Boolean(
|
||||
@@ -68,7 +80,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
|
||||
if (opts.client) {
|
||||
return opts.client;
|
||||
}
|
||||
const token = resolveToken(opts.token, opts.accountId);
|
||||
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
|
||||
return mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token);
|
||||
}
|
||||
|
||||
@@ -160,7 +172,8 @@ export async function listSlackReactions(
|
||||
export async function sendSlackMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: SlackActionClientOpts & {
|
||||
opts: Omit<SlackActionClientOpts, "cfg"> & {
|
||||
cfg: OpenClawConfig;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
@@ -172,10 +185,11 @@ export async function sendSlackMessage(
|
||||
uploadFileName?: string;
|
||||
uploadTitle?: string;
|
||||
blocks?: (Block | KnownBlock)[];
|
||||
} = {},
|
||||
},
|
||||
) {
|
||||
return await sendMessageSlack(to, content, {
|
||||
accountId: opts.accountId,
|
||||
cfg: opts.cfg,
|
||||
token: opts.token,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
mediaAccess: opts.mediaAccess,
|
||||
|
||||
@@ -216,6 +216,11 @@ describe("slackPlugin actions", () => {
|
||||
expect(sendMessageSlackMock).toHaveBeenCalledWith(
|
||||
"user:U12345678",
|
||||
expect.stringContaining("approved"),
|
||||
expect.objectContaining({
|
||||
accountId: "work",
|
||||
cfg,
|
||||
token: "xoxb-work",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import { slackOutbound } from "./outbound-adapter.js";
|
||||
import type { SlackProbe } from "./probe.js";
|
||||
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
|
||||
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
|
||||
import { getOptionalSlackRuntime } from "./runtime.js";
|
||||
import { fetchSlackScopes } from "./scopes.js";
|
||||
import { slackSecurityAdapter } from "./security.js";
|
||||
import { slackSetupAdapter } from "./setup-core.js";
|
||||
@@ -153,9 +153,8 @@ async function resolveSlackSendContext(params: {
|
||||
resolveOutboundSendDep<SlackSendFn>(params.deps, "slack") ??
|
||||
(await loadSlackSendRuntime()).sendMessageSlack;
|
||||
// params.cfg is the scoped channel-dispatch config; channel credentials are
|
||||
// expected to be resolved here (not a raw loadConfig() snapshot). Strict mode
|
||||
// is intentional so boot-time misconfigurations surface loudly. See #68237
|
||||
// for the companion tolerant-mode path in sendMessageSlack itself.
|
||||
// expected to be resolved from this snapshot. Strict mode
|
||||
// is intentional so boot-time misconfigurations surface loudly. See #68237.
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
@@ -513,23 +512,18 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
idLabel: "slackUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i),
|
||||
notify: async ({ id, message }) => {
|
||||
const cfg = getSlackRuntime().config.loadConfig();
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: resolveDefaultSlackAccountId(cfg),
|
||||
});
|
||||
const { sendMessageSlack } = await loadSlackSendRuntime();
|
||||
const token = getTokenForOperation(account, "write");
|
||||
const botToken = account.botToken?.trim();
|
||||
const tokenOverride = token && token !== botToken ? token : undefined;
|
||||
if (tokenOverride) {
|
||||
await sendMessageSlack(`user:${id}`, message, {
|
||||
token: tokenOverride,
|
||||
});
|
||||
} else {
|
||||
await sendMessageSlack(`user:${id}`, message);
|
||||
}
|
||||
await sendMessageSlack(`user:${id}`, message, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
...(token ? { token } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ type DraftEditFn = NonNullable<DraftStreamParams["edit"]>;
|
||||
type DraftRemoveFn = NonNullable<DraftStreamParams["remove"]>;
|
||||
type DraftWarnFn = NonNullable<DraftStreamParams["warn"]>;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
function createDraftStreamHarness(
|
||||
params: {
|
||||
maxChars?: number;
|
||||
@@ -27,6 +29,7 @@ function createDraftStreamHarness(
|
||||
const warn = params.warn ?? vi.fn<DraftWarnFn>();
|
||||
const stream = createSlackDraftStream({
|
||||
target: "channel:C123",
|
||||
cfg: TEST_CFG,
|
||||
token: "xoxb-test",
|
||||
throttleMs: 250,
|
||||
maxChars: params.maxChars,
|
||||
@@ -50,6 +53,7 @@ describe("createSlackDraftStream", () => {
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(edit).toHaveBeenCalledTimes(1);
|
||||
expect(edit).toHaveBeenCalledWith("C123", "111.222", "hello world", {
|
||||
cfg: TEST_CFG,
|
||||
token: "xoxb-test",
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
@@ -20,6 +21,7 @@ export type SlackDraftStream = {
|
||||
|
||||
export function createSlackDraftStream(params: {
|
||||
target: string;
|
||||
cfg: OpenClawConfig;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
maxChars?: number;
|
||||
@@ -63,12 +65,14 @@ export function createSlackDraftStream(params: {
|
||||
try {
|
||||
if (streamChannelId && streamMessageId) {
|
||||
await edit(streamChannelId, streamMessageId, trimmed, {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sent = await send(params.target, trimmed, {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
threadTs: params.resolveThreadTs?.(),
|
||||
|
||||
@@ -443,6 +443,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
return;
|
||||
}
|
||||
await deliverReplies({
|
||||
cfg: ctx.cfg,
|
||||
replies: [params.payload],
|
||||
target: prepared.replyTarget,
|
||||
token: ctx.botToken,
|
||||
@@ -677,6 +678,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
const draftStream = shouldUseDraftStream
|
||||
? createSlackDraftStream({
|
||||
target: prepared.replyTarget,
|
||||
cfg,
|
||||
token: ctx.botToken,
|
||||
accountId: account.accountId,
|
||||
maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT),
|
||||
|
||||
@@ -235,6 +235,7 @@ async function authorizeSlackInboundMessage(params: {
|
||||
resolveSenderName: ctx.resolveUserName,
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageSlack(message.channel, text, {
|
||||
cfg: ctx.cfg,
|
||||
token: ctx.botToken,
|
||||
client: ctx.app.client,
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -10,8 +10,11 @@ let createSlackReplyDeliveryPlan: typeof import("./replies.js").createSlackReply
|
||||
let resolveSlackThreadTs: typeof import("./replies.js").resolveSlackThreadTs;
|
||||
import { deliverSlackSlashReplies } from "./replies.js";
|
||||
|
||||
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
|
||||
|
||||
function baseParams(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
cfg: SLACK_TEST_CFG,
|
||||
replies: [{ text: "hello" }],
|
||||
target: "C123",
|
||||
token: "xoxb-test",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
@@ -22,6 +22,7 @@ export function readSlackReplyBlocks(payload: ReplyPayload) {
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
token: string;
|
||||
@@ -52,6 +53,7 @@ export async function deliverReplies(params: {
|
||||
continue;
|
||||
}
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
threadTs,
|
||||
accountId: params.accountId,
|
||||
@@ -76,6 +78,7 @@ export async function deliverReplies(params: {
|
||||
: undefined,
|
||||
sendText: async (trimmed) => {
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
threadTs,
|
||||
accountId: params.accountId,
|
||||
@@ -84,6 +87,7 @@ export async function deliverReplies(params: {
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
await sendMessageSlack(params.target, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
|
||||
@@ -3,12 +3,14 @@ import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.
|
||||
|
||||
installSlackBlockTestMocks();
|
||||
const { sendMessageSlack } = await import("./send.js");
|
||||
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
|
||||
|
||||
describe("sendMessageSlack NO_REPLY guard", () => {
|
||||
it("suppresses NO_REPLY text before any Slack API call", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
const result = await sendMessageSlack("channel:C123", "NO_REPLY", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -20,6 +22,7 @@ describe("sendMessageSlack NO_REPLY guard", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
const result = await sendMessageSlack("channel:C123", " NO_REPLY ", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -31,6 +34,7 @@ describe("sendMessageSlack NO_REPLY guard", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
await sendMessageSlack("channel:C123", "This is not a NO_REPLY situation", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -41,6 +45,7 @@ describe("sendMessageSlack NO_REPLY guard", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
const result = await sendMessageSlack("channel:C123", "NO_REPLY", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text: "content" } }],
|
||||
});
|
||||
@@ -57,6 +62,7 @@ describe("sendMessageSlack chunking", () => {
|
||||
|
||||
await sendMessageSlack("channel:C123", message, {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -75,6 +81,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
const result = await sendMessageSlack("channel:C123", "", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [{ type: "divider" }],
|
||||
});
|
||||
@@ -94,6 +101,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
await sendMessageSlack("channel:C123", "", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Build chart" }],
|
||||
});
|
||||
@@ -109,6 +117,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
await sendMessageSlack("channel:C123", "", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [
|
||||
{
|
||||
@@ -132,6 +141,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
const client = createSlackSendTestClient();
|
||||
await sendMessageSlack("channel:C123", "", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [{ type: "file", source: "remote", external_id: "F123" }],
|
||||
});
|
||||
@@ -148,6 +158,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
await expect(
|
||||
sendMessageSlack("channel:C123", "hi", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
blocks: [{ type: "divider" }],
|
||||
@@ -161,6 +172,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
await expect(
|
||||
sendMessageSlack("channel:C123", "hi", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [],
|
||||
}),
|
||||
@@ -174,6 +186,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
await expect(
|
||||
sendMessageSlack("channel:C123", "hi", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks,
|
||||
}),
|
||||
@@ -186,6 +199,7 @@ describe("sendMessageSlack blocks", () => {
|
||||
await expect(
|
||||
sendMessageSlack("channel:C123", "hi", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
blocks: [{} as { type: string }],
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Block, type KnownBlock, type WebClient } from "@slack/web-api";
|
||||
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import {
|
||||
@@ -49,7 +49,7 @@ export type SlackSendIdentity = {
|
||||
};
|
||||
|
||||
type SlackSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
@@ -310,7 +310,7 @@ async function uploadSlackFile(params: {
|
||||
export async function sendMessageSlack(
|
||||
to: string,
|
||||
message: string,
|
||||
opts: SlackSendOpts = {},
|
||||
opts: SlackSendOpts,
|
||||
): Promise<SlackSendResult> {
|
||||
const trimmedMessage = normalizeOptionalString(message) ?? "";
|
||||
if (isSilentReplyText(trimmedMessage) && !opts.mediaUrl && !opts.blocks) {
|
||||
@@ -321,21 +321,10 @@ export async function sendMessageSlack(
|
||||
if (!trimmedMessage && !opts.mediaUrl && !blocks) {
|
||||
throw new Error("Slack send requires text, blocks, or media");
|
||||
}
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
// Tolerate unresolved channel SecretRefs in the cfg snapshot here: the
|
||||
// send path either receives an explicit `opts.token` (resolved at Slack
|
||||
// monitor boot time and threaded through `ctx.botToken`) or surfaces the
|
||||
// existing "Slack bot token missing" error via `resolveToken` below. The
|
||||
// runtime snapshot can legitimately retain unresolved `channels.slack.*`
|
||||
// SecretRefs (see the inspect/strict separation introduced in #66818) when
|
||||
// the active account's secrets were not part of the agent-runtime base
|
||||
// target set; failing the strict resolver here would block outbound
|
||||
// replies even though `reactions.add` and inbound dispatch (which use the
|
||||
// boot-resolved client/token directly) keep working. See #68237.
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Slack send");
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
tolerateUnresolvedSecrets: true,
|
||||
});
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("./runtime-api.js", async () => {
|
||||
let sendMessageSlack: typeof import("./send.js").sendMessageSlack;
|
||||
let clearSlackDmChannelCache: typeof import("./send.js").clearSlackDmChannelCache;
|
||||
({ sendMessageSlack, clearSlackDmChannelCache } = await import("./send.js"));
|
||||
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
|
||||
|
||||
type UploadTestClient = WebClient & {
|
||||
conversations: { open: ReturnType<typeof vi.fn> };
|
||||
@@ -96,6 +97,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
// Bare user ID — parseSlackTarget classifies this as kind="channel"
|
||||
await sendMessageSlack("U2ZH3MFSR", "screenshot", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/screenshot.png",
|
||||
});
|
||||
@@ -118,6 +120,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("user:UABC123", "image", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/photo.png",
|
||||
});
|
||||
@@ -135,10 +138,12 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("user:UABC123", "first", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
await sendMessageSlack("user:UABC123", "second", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -158,10 +163,12 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("user:UABC123", "first", {
|
||||
token: "xoxb-test-a",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
await sendMessageSlack("user:UABC123", "second", {
|
||||
token: "xoxb-test-b",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -173,6 +180,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("channel:C123CHAN", "chart", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/chart.png",
|
||||
});
|
||||
@@ -188,6 +196,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("<@U777TEST>", "report", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/report.png",
|
||||
});
|
||||
@@ -205,6 +214,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("channel:C123CHAN", "caption", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/threaded.png",
|
||||
threadTs: "171.222",
|
||||
@@ -241,6 +251,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("channel:C123CHAN", "caption", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/threaded.png",
|
||||
uploadFileName: "custom-name.bin",
|
||||
@@ -263,6 +274,7 @@ describe("sendMessageSlack file upload with user IDs", () => {
|
||||
|
||||
await sendMessageSlack("channel:C123CHAN", "caption", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
mediaUrl: "/tmp/threaded.png",
|
||||
uploadFileName: "custom-name.bin",
|
||||
|
||||
@@ -810,7 +810,7 @@ export const registerTelegramHandlers = ({
|
||||
if (user?.is_bot) {
|
||||
return;
|
||||
}
|
||||
if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId)) {
|
||||
if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId, cfg)) {
|
||||
logVerbose(
|
||||
`telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`,
|
||||
);
|
||||
|
||||
@@ -1136,7 +1136,7 @@ export const registerTelegramNativeCommands = ({
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
buttons: telegramResultData?.buttons,
|
||||
});
|
||||
recordSentMessage(chatId, progressMessageId);
|
||||
recordSentMessage(chatId, progressMessageId, runtimeCfg);
|
||||
emitTelegramMessageSentHooks({
|
||||
sessionKeyForInternalHooks: route.sessionKey,
|
||||
chatId: String(chatId),
|
||||
|
||||
@@ -131,6 +131,7 @@ export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance
|
||||
});
|
||||
const threadBindingManager = threadBindingPolicy.enabled
|
||||
? createTelegramThreadBindingManager({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
|
||||
cfg,
|
||||
|
||||
@@ -640,8 +640,9 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
: null;
|
||||
},
|
||||
shouldStripThreadFromAnnounceOrigin: shouldStripTelegramThreadFromAnnounceOrigin,
|
||||
createManager: ({ accountId }) =>
|
||||
createManager: ({ cfg, accountId }) =>
|
||||
createTelegramThreadBindingManager({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
@@ -982,7 +983,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
throw new Error("telegram token not configured");
|
||||
}
|
||||
const send = await resolveTelegramSend();
|
||||
await send(id, message, { token, accountId });
|
||||
await send(id, message, { cfg, token, accountId });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -72,6 +72,9 @@ let sendMessageTelegram: typeof import("./send.js").sendMessageTelegram;
|
||||
|
||||
describe("telegram proxy client", () => {
|
||||
const proxyUrl = "http://proxy.test:8080";
|
||||
const TELEGRAM_PROXY_CFG = {
|
||||
channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } },
|
||||
};
|
||||
|
||||
const prepareProxyFetch = () => {
|
||||
const proxyFetch = vi.fn();
|
||||
@@ -108,9 +111,7 @@ describe("telegram proxy client", () => {
|
||||
botApi.setMessageReaction.mockResolvedValue(undefined);
|
||||
botApi.deleteMessage.mockResolvedValue(true);
|
||||
botCtorSpy.mockClear();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } },
|
||||
});
|
||||
loadConfig.mockReturnValue(TELEGRAM_PROXY_CFG);
|
||||
makeProxyFetch.mockClear();
|
||||
resolveTelegramFetch.mockClear();
|
||||
});
|
||||
@@ -120,8 +121,16 @@ describe("telegram proxy client", () => {
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
await sendMessageTelegram("123", "first", { token: "tok", accountId: "foo" });
|
||||
await sendMessageTelegram("123", "second", { token: "tok", accountId: "foo" });
|
||||
await sendMessageTelegram("123", "first", {
|
||||
cfg: TELEGRAM_PROXY_CFG,
|
||||
token: "tok",
|
||||
accountId: "foo",
|
||||
});
|
||||
await sendMessageTelegram("123", "second", {
|
||||
cfg: TELEGRAM_PROXY_CFG,
|
||||
token: "tok",
|
||||
accountId: "foo",
|
||||
});
|
||||
|
||||
expect(makeProxyFetch).toHaveBeenCalledTimes(1);
|
||||
expect(resolveTelegramFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -145,15 +154,30 @@ describe("telegram proxy client", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "sendMessage",
|
||||
run: () => sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }),
|
||||
run: () =>
|
||||
sendMessageTelegram("123", "hi", {
|
||||
cfg: TELEGRAM_PROXY_CFG,
|
||||
token: "tok",
|
||||
accountId: "foo",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "reactions",
|
||||
run: () => reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }),
|
||||
run: () =>
|
||||
reactMessageTelegram("123", "456", "✅", {
|
||||
cfg: TELEGRAM_PROXY_CFG,
|
||||
token: "tok",
|
||||
accountId: "foo",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "deleteMessage",
|
||||
run: () => deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }),
|
||||
run: () =>
|
||||
deleteMessageTelegram("123", "456", {
|
||||
cfg: TELEGRAM_PROXY_CFG,
|
||||
token: "tok",
|
||||
accountId: "foo",
|
||||
}),
|
||||
},
|
||||
])("uses proxy fetch for $name", async (testCase) => {
|
||||
const { fetchImpl } = prepareProxyFetch();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { loadConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { requireRuntimeConfig, resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { PollInput, MediaKind } from "openclaw/plugin-sdk/media-runtime";
|
||||
export {
|
||||
buildOutboundMediaLoadOptions,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user