fix(slack): route message actions by target account

This commit is contained in:
Peter Steinberger
2026-05-02 05:56:06 +01:00
parent 49be9a15fe
commit d4bdd40c92
3 changed files with 117 additions and 8 deletions

View File

@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh.
- Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=<user id>)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina.
- Slack/routing: match route bindings written with Slack target syntax such as `channel:C...`, `user:U...`, or `<@U...>`, so bound Slack peers route to the configured agent instead of `main`. Fixes #41608. Thanks @Winnsolutionsadmin.
- Slack/message actions: prefer the account bound to the outbound target peer before falling back to the agent's first channel account, so multi-workspace sends use the intended Slack account. Supersedes #66807. Thanks @rijhsinghani.
- Slack/delivery: retry Slack Web API writes only when the SDK wraps a DNS request failure such as `EAI_AGAIN`, so transient resolver hiccups can recover without retrying platform errors that may duplicate messages. Fixes #68789. Thanks @sonnyb9.
- Slack/message actions: forward agent-scoped media roots through the bundled upload-file action path, so workspace files can be attached without failing the local-media guard. Fixes #64625. Thanks @benpchandler.
- Slack/mentions: resolve `<!subteam^...>` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack.

View File

@@ -1320,13 +1320,45 @@ describe("runMessageAction plugin dispatch", () => {
},
expectedAccountId: "account-b",
},
{
name: "prefers the account bound to the target peer",
args: {
cfg: {
bindings: [
{
agentId: "agent-b",
match: {
channel: "accountchat",
accountId: "wrong-peer",
peer: { kind: "channel", id: "C_OTHER" },
},
},
{
agentId: "agent-b",
match: {
channel: "accountchat",
accountId: "account-peer",
peer: { kind: "channel", id: "C_TARGET" },
},
},
{
agentId: "agent-b",
match: { channel: "accountchat", accountId: "agent-fallback" },
},
],
} as OpenClawConfig,
agentId: "agent-b",
target: "channel:C_TARGET",
},
expectedAccountId: "account-peer",
},
])("$name", async ({ args, expectedAccountId }) => {
await runMessageAction({
...args,
action: "send",
params: {
channel: "accountchat",
target: "channel:123",
target: "target" in args ? args.target : "channel:123",
message: "hi",
},
});

View File

@@ -6,6 +6,7 @@ import {
readStringParam,
} from "../../agents/tools/common.js";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js";
import type {
@@ -25,8 +26,7 @@ import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
import { hasPollCreationParams } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -72,6 +72,7 @@ import {
} from "./outbound-policy.js";
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js";
import { normalizeTargetForProvider } from "./target-normalization.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
import { extractToolPayload } from "./tool-payload.js";
@@ -286,6 +287,80 @@ async function resolveChannel(
return selection.channel;
}
function addCandidateAndUnprefixedAlias(candidates: Set<string>, value?: string | null) {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return;
}
candidates.add(normalized);
const unprefixed = normalized.replace(/^(channel|group|user):/i, "").trim();
if (unprefixed && unprefixed !== normalized) {
candidates.add(unprefixed);
}
}
function normalizeTargetForAccountBinding(channel: ChannelId, target: string): string | undefined {
try {
return normalizeTargetForProvider(channel, target);
} catch {
return undefined;
}
}
function inferPeerKindForAccountBinding(channel: ChannelId, target: string): ChatType | undefined {
const inferred = normalizeChatType(
getChannelPlugin(channel)?.messaging?.inferTargetChatType?.({ to: target }),
);
if (inferred) {
return inferred;
}
const normalized = normalizeTargetForAccountBinding(channel, target);
const candidates = [target, normalized].filter((value): value is string => Boolean(value));
if (candidates.some((value) => /^user:/i.test(value))) {
return "direct";
}
if (candidates.some((value) => /^(channel|group):/i.test(value))) {
return "channel";
}
return undefined;
}
function resolveTargetBoundAccountId(params: {
cfg: OpenClawConfig;
channel: ChannelId;
args: Record<string, unknown>;
agentId?: string;
}): string | undefined {
if (!params.agentId) {
return undefined;
}
const target =
normalizeOptionalString(params.args.to) ?? normalizeOptionalString(params.args.channelId) ?? "";
if (!target) {
return resolveFirstBoundAccountId({
cfg: params.cfg,
channelId: params.channel,
agentId: params.agentId,
});
}
const candidates = new Set<string>();
addCandidateAndUnprefixedAlias(candidates, target);
addCandidateAndUnprefixedAlias(
candidates,
normalizeTargetForAccountBinding(params.channel, target),
);
const [peerId, ...exactPeerIdAliases] = Array.from(candidates);
return resolveFirstBoundAccountId({
cfg: params.cfg,
channelId: params.channel,
agentId: params.agentId,
peerId,
exactPeerIdAliases,
peerKind: inferPeerKindForAccountBinding(params.channel, target),
});
}
async function resolveActionTarget(params: {
cfg: OpenClawConfig;
channel: ChannelId;
@@ -941,11 +1016,12 @@ export async function runMessageAction(
const channel = await resolveChannel(cfg, params, input.toolContext);
let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
if (!accountId && resolvedAgentId) {
const byAgent = buildChannelAccountBindings(cfg).get(channel);
const boundAccountIds = byAgent?.get(normalizeAgentId(resolvedAgentId));
if (boundAccountIds && boundAccountIds.length > 0) {
accountId = boundAccountIds[0];
}
accountId = resolveTargetBoundAccountId({
cfg,
channel,
args: params,
agentId: resolvedAgentId,
});
}
if (accountId) {
params.accountId = accountId;