fix(slack): improve bot parity

This commit is contained in:
Peter Steinberger
2026-05-10 13:58:27 +01:00
parent b0c7249c64
commit 8654144606
10 changed files with 139 additions and 21 deletions

View File

@@ -29,6 +29,8 @@ Docs: https://docs.openclaw.ai
- Codex app-server: report Codex-native tool execution to diagnostics so long-running native `bash`, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)
- Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.
- Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.
- Slack: support `allowBots: "mentions"` for bot-authored messages that mention the receiving bot, matching the documented Discord-style mode without accepting every bot message. Fixes #43587. (#43588) Thanks @raw34.
- Gateway/agents: keep structured reasons when active-run queueing fails and deprecate the legacy boolean queue helper, so steering and subagent wake diagnostics distinguish completed, non-streaming, and compacting runs. Fixes #80156. Thanks @markus-lassfolk.
- Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines.
- ACPX: run and await the embedded ACP backend startup probe by default so the gateway `ready` signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0` to restore lazy startup. Fixes #79596. Thanks @bzelones.

View File

@@ -1,4 +1,4 @@
da702349b376821e0bc1420a945287dea0bccc79298e269abb028718983e94a5 config-baseline.json
8c647da77392bd4e87aac07fbdfc7592bbd656dc09f8844759d2c65dc374bd0d config-baseline.core.json
80f0f51caedf14dc2138d975b62852ff7c5cf085df1c734c9de279f5859a7eeb config-baseline.channel.json
dba159f639977bb96d79f0b78de2c6de48d25ed6ba1590f55812affb7ca6e4b0 config-baseline.plugin.json
f8d50da8d51a648598ed9165a6994af254e73e64ad037dc26b4742198b078a8c config-baseline.json
67c58457ed2b525975cdb053489f92a5f840c8cf982666393e111fd327dd132e config-baseline.core.json
a543b4d5132b4b0bcafa38e20d9ad07c78df1dc2b73633a6fdb03990cf3af918 config-baseline.channel.json
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json

View File

@@ -88,6 +88,30 @@ describe("createSlackDraftStream", () => {
);
});
it("forwards identity to the initial send call", async () => {
const identity = { username: "test-agent", iconEmoji: ":robot_face:" };
const send = vi.fn<DraftSendFn>(async () => slackDraftSendResult("111.222"));
const stream = createSlackDraftStream({
target: "channel:C123",
cfg: TEST_CFG,
token: "xoxb-test",
throttleMs: 250,
identity,
send,
edit: vi.fn<DraftEditFn>(async () => {}),
remove: vi.fn<DraftRemoveFn>(async () => {}),
});
stream.update("hello");
await stream.flush();
expect(send).toHaveBeenCalledWith(
"channel:C123",
"hello",
expect.objectContaining({ identity }),
);
});
it("does not send duplicate text", async () => {
const { stream, send, edit } = createDraftStreamHarness();

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import type { SlackSendIdentity } from "./send.js";
import { sendMessageSlack } from "./send.js";
const DEFAULT_THROTTLE_MS = 1000;
@@ -32,6 +33,7 @@ export function createSlackDraftStream(params: {
cfg: OpenClawConfig;
token: string;
accountId?: string;
identity?: SlackSendIdentity;
maxChars?: number;
throttleMs?: number;
resolveThreadTs?: () => string | undefined;
@@ -92,6 +94,7 @@ export function createSlackDraftStream(params: {
token: params.token,
accountId: params.accountId,
threadTs: params.resolveThreadTs?.(),
identity: params.identity,
...(blocks ? { blocks } : {}),
});
streamChannelId = sent.channelId || streamChannelId;

View File

@@ -10,7 +10,7 @@ import { normalizeSlackSlug } from "./allow-list.js";
export type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
allowBots?: boolean;
allowBots?: boolean | "mentions";
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -21,7 +21,7 @@ export type SlackChannelConfigResolved = {
type SlackChannelConfigEntry = {
enabled?: boolean;
requireMention?: boolean;
allowBots?: boolean;
allowBots?: boolean | "mentions";
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;

View File

@@ -921,6 +921,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
cfg,
token: ctx.botToken,
accountId: account.accountId,
identity: slackIdentity,
maxChars: Math.min(ctx.textLimit, SLACK_TEXT_LIMIT),
resolveThreadTs: () => {
const ts = replyPlan.peekThreadTs();

View File

@@ -618,6 +618,84 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(members).not.toHaveBeenCalled();
});
it("drops bot-authored room messages without mention when allowBots is mentions", async () => {
const members = vi.fn();
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
appClient: { conversations: { members } } as unknown as App["client"],
defaultRequireMention: false,
channelsConfig: {
C123: { users: ["B0AGV8EQYA3"] },
},
});
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: "mentions" }),
createBotRoomMessage({ text: "status failed" }),
);
expect(prepared).toBeNull();
expect(members).not.toHaveBeenCalled();
});
it("allows bot-authored room messages with explicit mention when allowBots is mentions", async () => {
const members = vi.fn();
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
appClient: { conversations: { members } } as unknown as App["client"],
defaultRequireMention: false,
channelsConfig: {
C123: { users: ["B0AGV8EQYA3"] },
},
});
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: "mentions" }),
createBotRoomMessage({ text: "hey <@B1> status failed" }),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.RawBody).toContain("status failed");
expect(members).not.toHaveBeenCalled();
});
it("allows bot-authored DM messages when allowBots is mentions", async () => {
const slackCtx = createInboundSlackCtx({
cfg: {
channels: {
slack: { enabled: true },
},
} as OpenClawConfig,
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Bot" }) as any;
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ allowBots: "mentions" }),
createSlackMessage({
channel: "D123",
channel_type: "im",
text: "bot DM",
bot_id: "B0AGV8EQYA3",
subtype: "bot_message",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.RawBody).toContain("bot DM");
});
it("drops bot-authored room messages when owner presence lookup fails (#59284)", async () => {
const members = vi.fn().mockRejectedValue(new Error("missing_scope"));
const slackCtx = createInboundSlackCtx({

View File

@@ -101,7 +101,7 @@ type SlackConversationContext = {
isRoom: boolean;
isRoomish: boolean;
channelConfig: ReturnType<typeof resolveSlackChannelConfig> | null;
allowBots: boolean;
allowBotsMode: "off" | "all" | "mentions";
isBotMessage: boolean;
};
@@ -149,11 +149,13 @@ async function resolveSlackConversationContext(params: {
allowNameMatching: ctx.allowNameMatching,
})
: null;
const allowBots =
const allowBotsSetting =
channelConfig?.allowBots ??
account.config?.allowBots ??
cfg.channels?.slack?.allowBots ??
false;
const allowBotsMode: "off" | "all" | "mentions" =
allowBotsSetting === "mentions" ? "mentions" : allowBotsSetting ? "all" : "off";
return {
channelInfo,
@@ -164,7 +166,7 @@ async function resolveSlackConversationContext(params: {
isRoom,
isRoomish,
channelConfig,
allowBots,
allowBotsMode,
isBotMessage: Boolean(message.bot_id),
};
}
@@ -176,14 +178,14 @@ async function authorizeSlackInboundMessage(params: {
conversation: SlackConversationContext;
}): Promise<SlackAuthorizationContext | null> {
const { ctx, account, message, conversation } = params;
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBots } =
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBotsMode } =
conversation;
if (isBotMessage) {
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
return null;
}
if (!allowBots) {
if (allowBotsMode === "off") {
logVerbose(`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`);
return null;
}
@@ -273,7 +275,7 @@ export async function prepareSlackMessage(params: {
isRoom,
isRoomish,
channelConfig,
allowBots,
allowBotsMode,
isBotMessage,
} = conversation;
const authorization = await authorizeSlackInboundMessage({
@@ -463,6 +465,8 @@ export async function prepareSlackMessage(params: {
...(ctx.threadRequireExplicitMention ? { allowedImplicitMentionKinds: [] } : {}),
},
});
const effectiveWasMentioned = messageIngress.activationAccess.effectiveWasMentioned ?? false;
const shouldBypassMention = messageIngress.activationAccess.shouldBypassMention ?? false;
const senderGate = messageIngress.senderAccess.gate;
if (isRoom && senderGate?.allowed === false) {
logVerbose(`Blocked unauthorized slack sender ${senderId} (not in channel users)`);
@@ -471,7 +475,7 @@ export async function prepareSlackMessage(params: {
if (
isRoom &&
isBotMessage &&
allowBots &&
allowBotsMode !== "off" &&
!(await authorizeSlackBotRoomMessage({
ctx,
channelId: message.channel,
@@ -484,6 +488,14 @@ export async function prepareSlackMessage(params: {
return null;
}
if (isBotMessage && allowBotsMode === "mentions") {
const botMentioned = isDirectMessage || effectiveWasMentioned || shouldBypassMention;
if (!botMentioned) {
logVerbose("slack: drop bot message (allowBots=mentions, missing mention)");
return null;
}
}
const threadContextAllowFromLower = isRoom
? channelUsersAllowlistConfigured
? normalizeAllowListLower(channelConfig?.users)
@@ -508,8 +520,6 @@ export async function prepareSlackMessage(params: {
return null;
}
const effectiveWasMentioned = messageIngress.activationAccess.effectiveWasMentioned ?? false;
const shouldBypassMention = messageIngress.activationAccess.shouldBypassMention ?? false;
if (isRoom && shouldRequireMention && messageIngress.activationAccess.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
const pendingText = (message.text ?? "").trim();

View File

@@ -36,8 +36,8 @@ export type SlackChannelConfig = {
/** Optional tool policy overrides for this channel. */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). Set to "mentions" to only allow bot messages that @mention this bot. */
allowBots?: boolean | "mentions";
/** Allowlist of users that can invoke the bot in this channel. */
users?: Array<string | number>;
/** Optional skill filter for this channel. */
@@ -143,8 +143,8 @@ export type SlackAccountConfig = {
userToken?: string;
/** If true, restrict user token to read operations only. Default: true. */
userTokenReadOnly?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). Set to "mentions" to only allow bot messages that @mention this bot. */
allowBots?: boolean | "mentions";
/**
* Break-glass override: allow mutable identity matching (name/slug) in allowlists.
* Default behavior is ID-only matching.

View File

@@ -952,7 +952,7 @@ export const SlackChannelSchema = z
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
allowBots: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
@@ -1010,7 +1010,7 @@ export const SlackAccountSchema = z
appToken: SecretInputSchema.optional().register(sensitive),
userToken: SecretInputSchema.optional().register(sensitive),
userTokenReadOnly: z.boolean().optional().default(true),
allowBots: z.boolean().optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),