mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user