fix(channels): thread runtime config through sends

This commit is contained in:
Peter Steinberger
2026-04-22 06:14:12 +01:00
parent e1897419de
commit 95331e5cc5
125 changed files with 1461 additions and 804 deletions

View File

@@ -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,

View File

@@ -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 },
);
});
});

View File

@@ -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 } : {}),
});
},
},
},

View File

@@ -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({

View File

@@ -272,6 +272,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
text: buildPluginBindingResolvedText(resolved),
},
{
cfg: params.ctx.cfg,
accountId: params.ctx.accountId,
},
);

View File

@@ -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) {

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
},

View File

@@ -113,7 +113,7 @@ function resolveDiscordWebhookIdentity(params: {
}
async function maybeSendDiscordWebhookText(params: {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
text: string;
threadId?: string | number | null;
accountId?: string | null;

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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),

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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"];

View File

@@ -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?: {

View File

@@ -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,

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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({

View File

@@ -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,
});
},
},
},

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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" },
);
});
});

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -117,6 +117,7 @@ describe("linePlugin outbound.sendPayload", () => {
"line:user:1",
"OpenClaw: your access has been approved.",
{
cfg,
accountId: "primary",
channelAccessToken: "token-primary",
},

View File

@@ -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,
});

View File

@@ -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)}`));

View File

@@ -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",
});
});

View File

@@ -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,
});
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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 } : {}),
});
},
};
}

View File

@@ -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" },
);
});

View File

@@ -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;

View File

@@ -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" });

View File

@@ -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");

View File

@@ -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)",
);
});

View File

@@ -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;

View File

@@ -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",
},

View File

@@ -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({

View File

@@ -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),

View File

@@ -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,

View File

@@ -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);

View File

@@ -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({

View File

@@ -356,6 +356,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
needsRoomAliasesForConfig,
});
threadBindingManager = await createMatrixThreadBindingManager({
cfg,
accountId: effectiveAccountId,
auth,
client,

View File

@@ -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({

View File

@@ -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",

View File

@@ -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";

View File

@@ -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") {

View File

@@ -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[];

View File

@@ -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,

View File

@@ -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,

View File

@@ -196,6 +196,7 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
const result = await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, message, {
cfg,
accountId: resolvedAccountId,
replyToId,
buttons: presentation

View File

@@ -16,7 +16,7 @@ type SendMattermostMessage = (
to: string,
text: string,
opts: {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];

View File

@@ -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,
});

View File

@@ -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" });

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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";

View File

@@ -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);

View File

@@ -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,
});
},
},
},

View File

@@ -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,

View File

@@ -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" }),
}),
}),
}),
);
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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"]);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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 } : {}),
};

View File

@@ -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,

View File

@@ -216,6 +216,11 @@ describe("slackPlugin actions", () => {
expect(sendMessageSlackMock).toHaveBeenCalledWith(
"user:U12345678",
expect.stringContaining("approved"),
expect.objectContaining({
accountId: "work",
cfg,
token: "xoxb-work",
}),
);
});

View File

@@ -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 } : {}),
});
},
},
},

View File

@@ -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,
});

View File

@@ -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?.(),

View File

@@ -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),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 }],
}),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)`,
);

View File

@@ -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),

View File

@@ -131,6 +131,7 @@ export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance
});
const threadBindingManager = threadBindingPolicy.enabled
? createTelegramThreadBindingManager({
cfg,
accountId: account.accountId,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg,

View File

@@ -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 });
},
},
},

View File

@@ -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();

View File

@@ -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