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