fix(slack): correct user-identity self-loop and action discovery

Review fixes for the identityMode="user" path:

- Drop inbound messages authored by our own identity regardless of bot_id.
  In user mode the agent's own reply echoes back as an ordinary user message
  (event.user === botUserId, no bot_id), which the previous isBotMessage-gated
  self-drop skipped, causing a self-reply loop.
- Expose message-action tools for user-only accounts (no bot token), and gate
  user-mode mutation actions on a writable user token (userTokenReadOnly=false)
  to mirror resolveSlackOperationToken.

Regression tests: self-echo suppression, counterparty pass-through, and
writable / read-only / credential-less action discovery.
This commit is contained in:
Peter Steinberger
2026-07-17 00:38:25 +00:00
parent f1bdb23607
commit 48ab69e5e2
4 changed files with 99 additions and 9 deletions
+11 -1
View File
@@ -12,7 +12,17 @@ export function listSlackMessageActions(
): ChannelMessageActionName[] {
const accounts = (
accountId ? [resolveSlackAccount({ cfg, accountId })] : listEnabledSlackAccounts(cfg)
).filter((account) => account.enabled && account.botTokenSource !== "none");
).filter((account) => {
if (!account.enabled) {
return false;
}
// User identity writes/reacts with the user token, which is only writable when
// userTokenReadOnly is explicitly false (mirrors resolveSlackOperationToken); otherwise the
// account has no actionable identity credential and must not advertise mutation actions.
return account.config.identityMode === "user"
? account.userTokenSource !== "none" && account.config.userTokenReadOnly === false
: account.botTokenSource !== "none";
});
if (accounts.length === 0) {
return [];
}
@@ -219,6 +219,59 @@ describe("Slack message tools", () => {
]);
});
it("lists default actions for a user-identity account without a bot token", () => {
const cfg = {
channels: {
slack: {
accounts: {
user: {
identityMode: "user",
userToken: "test",
userTokenReadOnly: false,
},
},
},
},
} as OpenClawConfig;
const actions = listSlackMessageActions(cfg, "user");
expect(actions).not.toHaveLength(0);
expect(actions).toEqual(expect.arrayContaining(["send", "react"]));
});
it("omits actions for a read-only user-identity account", () => {
const cfg = {
channels: {
slack: {
accounts: {
user: {
identityMode: "user",
userToken: "test",
userTokenReadOnly: true,
},
},
},
},
} as OpenClawConfig;
expect(listSlackMessageActions(cfg, "user")).toEqual([]);
});
it("omits actions for an account without an identity token", () => {
const cfg = {
channels: {
slack: {
accounts: {
empty: {},
},
},
},
} as OpenClawConfig;
expect(listSlackMessageActions(cfg, "empty")).toEqual([]);
});
it("honors the selected Slack account during discovery", () => {
const cfg = {
channels: {
@@ -1215,6 +1215,32 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(prepared.ctxPayload.RawBody).toContain("[Slack file: file]");
});
it("drops user-identity echoes without bot_id", async () => {
const slackCtx = createDefaultSlackCtx();
slackCtx.botUserId = "U_AUTH";
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ identityMode: "user" }),
createSlackMessage({ user: "U_AUTH" }),
);
expect(prepared).toBeNull();
});
it("allows user-identity messages from a different Slack user", async () => {
const slackCtx = createDefaultSlackCtx();
slackCtx.botUserId = "U_AUTH";
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ identityMode: "user" }),
createSlackMessage({ user: "U_COUNTERPARTY" }),
);
assertPrepared(prepared);
});
it("extracts attachment text for bot messages with empty text when allowBots is true (#27616)", async () => {
const slackCtx = createInboundSlackCtx({
cfg: {
@@ -559,14 +559,15 @@ async function authorizeSlackInboundMessage(params: {
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBotsMode } =
conversation;
if (isBotMessage) {
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
return null;
}
if (allowBotsMode === "off") {
logVerbose(`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`);
return null;
}
// Never process a message authored by our own identity. In bot mode this is the bot's echo;
// in identityMode="user" the agent's own reply comes back as an ordinary user message with no
// bot_id, so gating this on isBotMessage would let user-mode self-posts loop.
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
return null;
}
if (isBotMessage && allowBotsMode === "off") {
logVerbose(`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`);
return null;
}
if (isDirectMessage && !message.user) {