fix(slack): route Enterprise Grid messages by workspace (#120087)

* fix(slack): route Grid messages by workspace

* fix(slack): use authorized Grid DM sender

* fix(slack): preserve ordinary client calls in grid routing

* test(slack): keep grid target coverage in scoped suite

* fix(slack): preserve workspace scope in approval routes

* feat(slack): scope Grid uploads and reactions by workspace

* fix(slack): preserve ordinary action read targets

* fix(slack): reuse Grid clients for thread status

* fix(slack): infer current Grid workspace for native actions

* fix(slack): record streamed thread participation promptly

* fix(slack): scope Grid member info to current workspace

* feat(slack): support Grid write actions

* feat(slack): support Grid read actions

* fix(slack): remove obsolete channel resolver

* fix(slack): route current Grid outbound delivery

* test(slack): cover Grid thread participation

* test(slack): use valid Grid team ID

* fix(slack): keep Grid thread participation without bot identity

* fix(slack): enable Grid pin reads

* fix(slack): keep current Grid sends workspace aware

* test(slack): assert Grid send routing synchronously

* refactor(slack): remove obsolete Grid action allowlist

* refactor(slack): derive Grid channel routing targets

* refactor(slack): pass optional team scope uniformly

* test(slack): expect uniform client options

* refactor(slack): name unscoped cache key explicitly

* refactor(slack): always pass action options

* refactor(slack): pass optional team scope directly

* refactor(slack): build team-scoped action options once

* refactor(slack): centralize optional team target formatting

* refactor(slack): construct reconciliation clients from scope

* refactor(slack): resolve delivery policy before sending

* refactor(slack): clarify inbound target roles

* refactor(slack): minimize validated event scope

* fix(slack): route outbound workspace through target

* fix(slack): format post response errors safely

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
This commit is contained in:
Sarah Fortune
2026-08-08 15:38:24 -07:00
committed by GitHub
parent 08d2062c7f
commit cdaea2d6b6
40 changed files with 1660 additions and 466 deletions
+15 -8
View File
@@ -240,14 +240,21 @@ bot-authored `message` and `app_mention` events before dispatch, regardless of
`allowBots`, because org installs do not provide a stable workspace-qualified
bot identity for loop prevention.
Enterprise support is intentionally limited to direct Socket Mode or HTTP
`message` and `app_mention` events and their immediate replies. Relay mode,
slash commands, interactions, App Home, reaction event listeners, pins, Slack
action tools, Slack-native approvals, bindings, queued or scheduled delivery,
and proactive sends are unavailable for an enterprise account. Outbound
acknowledgment, typing, and status reactions are supported through the
listener-owned Slack client and require `reactions:write`; inbound reaction
notifications and reaction action tools remain unavailable.
Enterprise support accepts direct Socket Mode or HTTP `message` and
`app_mention` events plus workspace-qualified outbound messages. Relay mode,
slash commands, interactions, App Home, reaction event listeners, pins,
Slack-native approvals, and bindings remain unavailable for an enterprise
account. Slack action tools remain unavailable except for file uploads and
adding or removing emoji reactions. Outbound acknowledgment, typing, and
status reactions are supported and require `reactions:write`; inbound reaction
notifications remain unavailable.
OpenClaw records Enterprise Grid destinations as
`team:<team-id>:channel:<channel-id>` or `team:<team-id>:user:<user-id>`.
Current-conversation sends, uploads, and reactions inherit that destination.
Detached or proactive calls must provide the workspace-qualified target;
bare channel and user IDs fail closed because those IDs can be reused by
different workspaces.
Immediate replies reuse the standard Slack delivery behavior for chunks,
media, metadata, identity fallback, unfurls, and receipts, but only while the
+467 -4
View File
@@ -64,14 +64,245 @@ describe("handleSlackAction", () => {
} as OpenClawConfig;
}
it("rejects all actions before Slack API work for an enterprise org account", async () => {
it("reads pins from the trusted Enterprise Grid workspace", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
listSlackPins.mockResolvedValueOnce([]);
const result = await handleSlackAction({ action: "listPins", channelId: "C123" }, cfg, {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
});
expect(requireDetails(result).ok).toBe(true);
expect(requireMockArg(listSlackPins, "listSlackPins", 0, 0)).toBe("C123");
expectRecordFields(requireRecordArg(listSlackPins, "listSlackPins", 0, 1), {
cfg,
teamId: "T123",
});
});
it("reads the current requester from the trusted Enterprise Grid workspace", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
getSlackMemberInfo.mockResolvedValueOnce({
ok: true,
user: { id: "U123", is_bot: false },
});
const result = await handleSlackAction({ action: "memberInfo", userId: "U123" }, cfg, {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
requesterSenderId: "U123",
});
expect(getSlackMemberInfo).toHaveBeenCalledWith("U123", {
cfg,
teamId: "T123",
});
expect(requireDetails(result)).toEqual({
ok: true,
info: { ok: true, user: { id: "U123", is_bot: false } },
});
});
it.each([
{
name: "provider does not match",
context: {
currentChannelProvider: "discord",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
requesterSenderId: "U123",
},
},
{
name: "account does not match",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "other",
requesterSenderId: "U123",
},
},
{
name: "workspace is absent",
context: {
currentChannelProvider: "slack",
currentChannelId: "channel:C123",
requesterAccountId: "default",
requesterSenderId: "U123",
},
},
{
name: "current targets disagree on workspace",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
currentMessagingTarget: "team:T456:channel:C123",
requesterAccountId: "default",
requesterSenderId: "U123",
},
},
])("rejects Enterprise Grid member info when the trusted $name", async ({ context }) => {
await expect(
handleSlackAction(
{ action: "readMessages", channelId: "C123" },
{ action: "memberInfo", userId: "U123" },
slackConfig({ enterpriseOrgInstall: true }),
context,
),
).rejects.toThrow(/unavailable for Enterprise Grid org installs/);
expect(readSlackMessages).not.toHaveBeenCalled();
).rejects.toThrow();
expect(getSlackMemberInfo).not.toHaveBeenCalled();
});
it("scopes every Enterprise Grid message and pin write to the trusted current workspace", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
const context = {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
};
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "created" },
cfg,
context,
);
await handleSlackAction(
{
action: "editMessage",
channelId: "C123",
messageId: "123.456",
content: "updated",
},
cfg,
context,
);
await handleSlackAction(
{ action: "deleteMessage", channelId: "C123", messageId: "123.456" },
cfg,
context,
);
await handleSlackAction(
{ action: "pinMessage", channelId: "C123", messageId: "123.456" },
cfg,
context,
);
await handleSlackAction(
{ action: "unpinMessage", channelId: "C123", messageId: "123.456" },
cfg,
context,
);
expectSlackSendCall(0, "team:T123:channel:C123", "created", {
cfg,
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
});
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "updated", {
cfg,
teamId: "T123",
blocks: undefined,
});
expect(deleteSlackMessage).toHaveBeenCalledWith("C123", "123.456", {
cfg,
teamId: "T123",
});
expect(pinSlackMessage).toHaveBeenCalledWith("C123", "123.456", {
cfg,
teamId: "T123",
});
expect(unpinSlackMessage).toHaveBeenCalledWith("C123", "123.456", {
cfg,
teamId: "T123",
});
});
it("scopes Enterprise Grid history, file, reaction, and emoji reads to the trusted workspace", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
const context = {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
};
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
listSlackReactions.mockResolvedValueOnce([]);
downloadSlackFile.mockResolvedValueOnce(null);
listSlackEmojis.mockResolvedValueOnce({ ok: true, emoji: { openai: "url" } });
await handleSlackAction({ action: "readMessages", channelId: "C123" }, cfg, context);
await handleSlackAction(
{ action: "reactions", channelId: "C123", messageId: "123.456" },
cfg,
context,
);
await handleSlackAction(
{ action: "downloadFile", channelId: "C123", fileId: "F123" },
cfg,
context,
);
await handleSlackAction({ action: "emojiList" }, cfg, context);
expect(readSlackMessages).toHaveBeenCalledWith(
"C123",
expect.objectContaining({ cfg, teamId: "T123" }),
);
expect(listSlackReactions).toHaveBeenCalledWith("C123", "123.456", {
cfg,
teamId: "T123",
});
expect(downloadSlackFile).toHaveBeenCalledWith(
"F123",
expect.objectContaining({ cfg, teamId: "T123", channelId: "C123" }),
);
expect(listSlackEmojis).toHaveBeenCalledWith({ cfg, teamId: "T123" });
});
it.each([
{ name: "history", params: { action: "readMessages", channelId: "C123" } },
{
name: "reactions",
params: { action: "reactions", channelId: "C123", messageId: "123.456" },
},
{ name: "file", params: { action: "downloadFile", channelId: "C123", fileId: "F123" } },
{ name: "emoji", params: { action: "emojiList" } },
])("rejects a bare Enterprise Grid $name read without trusted context", async ({ params }) => {
await expect(
handleSlackAction(params, slackConfig({ enterpriseOrgInstall: true })),
).rejects.toThrow("unsupported_enterprise_slack_delivery");
});
it.each([
{
name: "send",
params: { action: "sendMessage", to: "channel:C123", content: "created" },
},
{
name: "edit",
params: {
action: "editMessage",
channelId: "C123",
messageId: "123.456",
content: "updated",
},
},
{
name: "delete",
params: { action: "deleteMessage", channelId: "C123", messageId: "123.456" },
},
{
name: "pin",
params: { action: "pinMessage", channelId: "C123", messageId: "123.456" },
},
{
name: "unpin",
params: { action: "unpinMessage", channelId: "C123", messageId: "123.456" },
},
])("rejects a bare Enterprise Grid $name without trusted current context", async ({ params }) => {
await expect(
handleSlackAction(params, slackConfig({ enterpriseOrgInstall: true })),
).rejects.toThrow("unsupported_enterprise_slack_delivery");
});
function createReplyToFirstContext(hasRepliedRef: { value: boolean }) {
@@ -310,6 +541,179 @@ describe("handleSlackAction", () => {
});
});
it("routes Enterprise Grid reactions through the target workspace client", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
const channelId = "team:T123:channel:C123";
await handleSlackAction(
{
action: "react",
channelId,
messageId: "123.456",
emoji: "✅",
},
cfg,
{
currentChannelProvider: "slack",
currentChannelId: channelId,
requesterAccountId: "default",
},
);
expect(reactSlackMessage).toHaveBeenCalledWith("C123", "123.456", "✅", {
cfg,
teamId: "T123",
});
});
it("qualifies a bare Enterprise Grid reaction target from the trusted current conversation", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
await handleSlackAction(
{
action: "react",
channelId: "C123",
messageId: "123.456",
emoji: "✅",
},
cfg,
{
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
},
);
expect(reactSlackMessage).toHaveBeenCalledWith("C123", "123.456", "✅", {
cfg,
teamId: "T123",
});
});
it.each([
{
name: "provider does not match",
context: {
currentChannelProvider: "discord",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
},
},
{
name: "account does not match",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "other",
},
},
{
name: "requester account is missing",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
},
},
{
name: "channel does not match",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C456",
requesterAccountId: "default",
},
},
{
name: "current target is not workspace-qualified",
context: {
currentChannelProvider: "slack",
currentChannelId: "channel:C123",
requesterAccountId: "default",
},
},
{
name: "current targets disagree on workspace",
context: {
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
currentMessagingTarget: "team:T456:channel:C123",
requesterAccountId: "default",
},
},
])(
"rejects bare Enterprise Grid reaction targets when the trusted $name",
async ({ context }) => {
await expect(
handleSlackAction(
{
action: "react",
channelId: "C123",
messageId: "123.456",
emoji: "✅",
},
slackConfig({ enterpriseOrgInstall: true }),
context,
),
).rejects.toThrow("unsupported_enterprise_slack_delivery");
expect(reactSlackMessage).not.toHaveBeenCalled();
},
);
it("routes Enterprise Grid reaction removal through the target workspace client", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
const channelId = "team:T123:channel:C123";
await handleSlackAction(
{
action: "react",
channelId,
messageId: "123.456",
emoji: "✅",
remove: true,
},
cfg,
{
currentChannelProvider: "slack",
currentChannelId: channelId,
requesterAccountId: "default",
},
);
expect(removeSlackReaction).toHaveBeenCalledWith("C123", "123.456", "✅", {
cfg,
teamId: "T123",
});
});
it("requires a workspace-qualified reaction target for Enterprise Grid", async () => {
await expect(
handleSlackAction(
{
action: "react",
channelId: "C123",
messageId: "123.456",
emoji: "✅",
},
slackConfig({ enterpriseOrgInstall: true }),
),
).rejects.toThrow("unsupported_enterprise_slack_delivery");
expect(reactSlackMessage).not.toHaveBeenCalled();
});
it("rejects workspace-qualified reaction targets for ordinary installs", async () => {
await expect(
handleSlackAction(
{
action: "react",
channelId: "team:T123:channel:C123",
messageId: "123.456",
emoji: "✅",
},
slackConfig(),
),
).rejects.toThrow("unexpected_enterprise_slack_workspace");
expect(reactSlackMessage).not.toHaveBeenCalled();
});
it("removes reactions on empty emoji", async () => {
const cfg = slackConfig();
await handleSlackAction(
@@ -638,6 +1042,65 @@ describe("handleSlackAction", () => {
});
});
it("routes Enterprise Grid uploads through a workspace-qualified destination", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
await handleSlackAction(
{
action: "uploadFile",
to: "team:T123:channel:C123",
filePath: "/tmp/report.png",
initialComment: "fresh report",
},
cfg,
);
expectSlackSendCall(0, "team:T123:channel:C123", "fresh report", {
cfg,
mediaUrl: "/tmp/report.png",
threadTs: undefined,
});
});
it("qualifies a bare Enterprise Grid upload destination from the trusted current conversation", async () => {
const cfg = slackConfig({ enterpriseOrgInstall: true });
await handleSlackAction(
{
action: "uploadFile",
to: "channel:C123",
filePath: "/tmp/report.png",
initialComment: "fresh report",
},
cfg,
{
currentChannelProvider: "slack",
currentChannelId: "team:T123:channel:C123",
requesterAccountId: "default",
},
);
expectSlackSendCall(0, "team:T123:channel:C123", "fresh report", {
cfg,
mediaUrl: "/tmp/report.png",
threadTs: undefined,
});
});
it("requires a workspace-qualified upload destination for Enterprise Grid", async () => {
await expect(
handleSlackAction(
{
action: "uploadFile",
to: "channel:C123",
filePath: "/tmp/report.png",
},
slackConfig({ enterpriseOrgInstall: true }),
),
).rejects.toThrow("unsupported_enterprise_slack_delivery");
expect(sendSlackMessage).not.toHaveBeenCalled();
});
it.each([
{
action: "sendMessage",
+185 -105
View File
@@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe
import type { ResolvedSlackAccount } from "./accounts.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import type { SlackConversationInfo } from "./channel-type.js";
import { assertSlackDirectSendAllowed } from "./direct-send-admission.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { resolveSlackChannelConfig } from "./monitor/channel-config.js";
import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js";
@@ -25,6 +26,7 @@ import {
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { formatSlackTarget } from "./target-parsing.js";
import { parseSlackTarget, resolveSlackChannelId, slackContextTargetsMatch } from "./targets.js";
type ConversationReadInvocationOrigin = NonNullable<
@@ -80,6 +82,7 @@ export const slackActionRuntime = {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
teamId?: string;
operation?: "read" | "write";
requireFreshName?: boolean;
}) => (await loadSlackChannelTypeRuntime()).resolveSlackConversationInfo(params),
@@ -241,7 +244,7 @@ async function isSlackDmTargetConfigured(params: {
function isCurrentSlackReadTarget(params: {
account: ResolvedSlackAccount;
channelId: string;
target: string;
context?: SlackActionContext;
}): boolean {
const requesterAccountId = params.context?.requesterAccountId?.trim();
@@ -250,7 +253,7 @@ function isCurrentSlackReadTarget(params: {
requesterAccountId &&
normalizeAccountId(requesterAccountId) === normalizeAccountId(params.account.accountId) &&
params.context &&
slackContextTargetsMatch(params.channelId, params.context),
slackContextTargetsMatch(params.target, params.context),
);
}
@@ -350,6 +353,7 @@ async function assertSlackReadTargetAllowed(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
teamId?: string;
conversationReadOrigin?: ConversationReadInvocationOrigin;
context?: SlackActionContext;
}) {
@@ -358,7 +362,11 @@ async function assertSlackReadTargetAllowed(params: {
};
const currentConversation = isCurrentSlackReadTarget({
account: params.account,
channelId: params.channelId,
target: formatSlackTarget({
teamId: params.teamId,
kind: "channel",
id: params.channelId,
}),
context: params.context,
});
const directOperator = params.conversationReadOrigin === "direct-operator";
@@ -373,6 +381,7 @@ async function assertSlackReadTargetAllowed(params: {
cfg: params.cfg,
accountId: params.account.accountId,
channelId: params.channelId,
teamId: params.teamId,
operation: "read",
});
if (
@@ -407,6 +416,7 @@ async function assertSlackReadTargetAllowed(params: {
cfg: params.cfg,
accountId: params.account.accountId,
channelId: params.channelId,
teamId: params.teamId,
operation: "read",
...(preliminary.shouldResolveName ? { requireFreshName: true } : {}),
});
@@ -470,29 +480,97 @@ function isSlackGroupDmTargetConfigured(account: ResolvedSlackAccount, channelId
});
}
type SlackActionChannelTarget = {
channelId: string;
teamId?: string;
};
function resolveTrustedCurrentSlackTeamId(params: {
account: ResolvedSlackAccount;
target?: NonNullable<ReturnType<typeof parseSlackTarget>>;
context?: SlackActionContext;
}): string | undefined {
const requesterAccountId = params.context?.requesterAccountId?.trim();
if (
params.account.config.enterpriseOrgInstall !== true ||
normalizeOptionalLowercaseString(params.context?.currentChannelProvider) !== "slack" ||
!requesterAccountId ||
normalizeAccountId(requesterAccountId) !== normalizeAccountId(params.account.accountId)
) {
return undefined;
}
const matchingTeams = new Map<string, string>();
for (const raw of [params.context?.currentChannelId, params.context?.currentMessagingTarget]) {
if (!raw) {
continue;
}
const current = parseSlackTarget(raw);
if (
current?.teamId &&
(!params.target ||
(current.kind === params.target.kind &&
current.id.toLowerCase() === params.target.id.toLowerCase()))
) {
matchingTeams.set(current.teamId.toLowerCase(), current.teamId);
}
}
return matchingTeams.size === 1 ? matchingTeams.values().next().value : undefined;
}
function resolveSlackActionTarget(
account: ResolvedSlackAccount,
raw: string,
context?: SlackActionContext,
) {
const parsed = parseSlackTarget(raw, { defaultKind: "channel" });
if (!parsed) {
throw new Error("Slack target is required.");
}
const teamId =
parsed.teamId ?? resolveTrustedCurrentSlackTeamId({ account, target: parsed, context });
assertSlackDirectSendAllowed(account, teamId);
return {
routingTarget: teamId ? formatSlackTarget({ teamId, kind: parsed.kind, id: parsed.id }) : raw,
teamId,
};
}
function resolveSlackActionChannelTarget(
account: ResolvedSlackAccount,
raw: string,
context?: SlackActionContext,
): SlackActionChannelTarget {
const resolved = resolveSlackActionTarget(account, raw, context);
const channelId = resolveSlackChannelId(raw);
return {
channelId,
teamId: resolved.teamId,
};
}
export async function handleSlackAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveSlackChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const { resolveSlackAccount, resolveSlackOperationToken } = await loadSlackAccountsRuntime();
const account = resolveSlackAccount({ cfg, accountId });
if (account.config.enterpriseOrgInstall === true) {
throw new Error("Slack action tools are unavailable for Enterprise Grid org installs.");
}
const resolveChannelTarget = () =>
resolveSlackActionChannelTarget(
account,
readStringParam(params, "channelId", {
required: true,
}),
context,
);
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const botToken = account.botToken?.trim();
const buildActionOpts = (operation: "read" | "write") => {
const buildActionOpts = (operation: "read" | "write", teamId?: string) => {
const token = resolveSlackOperationToken(account, operation);
if (!token && account.identity === "user") {
throw new Error(`Slack operation token missing for account "${account.accountId}".`);
@@ -502,62 +580,58 @@ export async function handleSlackAction(
cfg,
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
teamId,
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
const assertReadTargetAllowed = async (channelId: string) =>
const assertReadTargetAllowed = async (target: SlackActionChannelTarget) => {
await assertSlackReadTargetAllowed({
account,
cfg,
channelId,
channelId: target.channelId,
teamId: target.teamId,
conversationReadOrigin: context?.conversationReadOrigin,
context,
});
};
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled.");
}
const channelId = resolveChannelId();
const target = resolveChannelTarget();
const { channelId } = target;
const readOpts = buildActionOpts("read", target.teamId);
const writeOpts = buildActionOpts("write", target.teamId);
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
await assertReadTargetAllowed(channelId);
await assertReadTargetAllowed(target);
if (remove) {
if (writeOpts) {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji);
}
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = writeOpts
? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts)
: await slackActionRuntime.removeOwnSlackReactions(channelId, messageId);
const removed = await slackActionRuntime.removeOwnSlackReactions(
channelId,
messageId,
writeOpts,
);
return jsonResult({ ok: true, removed });
}
if (writeOpts) {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji);
}
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
return jsonResult({ ok: true, added: emoji });
}
await assertReadTargetAllowed(channelId);
await assertReadTargetAllowed(target);
const limit = Math.min(
readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
}) ?? SLACK_REACTION_USER_LIMIT,
SLACK_REACTION_USER_LIMIT,
);
const reactions = readOpts
? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts)
: await slackActionRuntime.listSlackReactions(channelId, messageId);
const reactions = await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts);
return jsonResult({
ok: true,
reactions: reactions?.map((reaction) =>
@@ -575,6 +649,8 @@ export async function handleSlackAction(
switch (action) {
case "sendMessage": {
const to = readStringParam(params, "to", { required: true });
const target = resolveSlackActionTarget(account, to, context);
const destination = target.routingTarget;
const content = readStringParam(params, "content", {
allowEmpty: true,
});
@@ -610,14 +686,14 @@ export async function handleSlackAction(
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
destination,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
const baseSendOpts = {
...writeOpts,
...buildActionOpts("write"),
mediaAccess: context?.mediaAccess,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
@@ -638,13 +714,13 @@ export async function handleSlackAction(
// Reuse the resolved thread for both sends. Invoking the action twice
// could consume replyToMode=first and move the full text off-thread.
const { replyBroadcast: _replyBroadcast, ...blockSendOpts } = sendOpts;
await slackActionRuntime.sendSlackMessage(to, "", {
await slackActionRuntime.sendSlackMessage(destination, "", {
...blockSendOpts,
blocks,
});
return await slackActionRuntime.sendSlackMessage(to, content, sendOpts);
return await slackActionRuntime.sendSlackMessage(destination, content, sendOpts);
}
return await slackActionRuntime.sendSlackMessage(to, content ?? "", {
return await slackActionRuntime.sendSlackMessage(destination, content ?? "", {
...sendOpts,
blocks,
});
@@ -655,13 +731,13 @@ export async function handleSlackAction(
| Awaited<ReturnType<typeof slackActionRuntime.sendSlackMessage>>
| undefined;
if (mediaUrl) {
lastResult = await slackActionRuntime.sendSlackMessage(to, "", {
lastResult = await slackActionRuntime.sendSlackMessage(destination, "", {
...baseSendOpts,
mediaUrl,
});
}
for (const [index, message] of preparedMessages.entries()) {
lastResult = await slackActionRuntime.sendSlackMessage(to, message.text, {
lastResult = await slackActionRuntime.sendSlackMessage(destination, message.text, {
...baseSendOpts,
...(index === 0 && replyBroadcast ? { replyBroadcast: true } : {}),
...(message.blocks ? { blocks: message.blocks } : {}),
@@ -682,14 +758,14 @@ export async function handleSlackAction(
: blocks
? await (async () => {
if (mediaUrl) {
await slackActionRuntime.sendSlackMessage(to, "", {
await slackActionRuntime.sendSlackMessage(destination, "", {
...sendOpts,
mediaUrl,
});
}
return await sendContentAndBlocks();
})()
: await slackActionRuntime.sendSlackMessage(to, content ?? "", {
: await slackActionRuntime.sendSlackMessage(destination, content ?? "", {
...sendOpts,
mediaUrl: mediaUrl ?? undefined,
blocks,
@@ -698,7 +774,7 @@ export async function handleSlackAction(
// Keep "first" mode consistent even when the agent explicitly provided
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && slackContextTargetsMatch(to, context)) {
if (context?.hasRepliedRef && slackContextTargetsMatch(destination, context)) {
context.hasRepliedRef.value = true;
}
@@ -706,6 +782,8 @@ export async function handleSlackAction(
}
case "uploadFile": {
const to = readStringParam(params, "to", { required: true });
const target = resolveSlackActionTarget(account, to, context);
const destination = target.routingTarget;
const filePath = readStringParam(params, "filePath", {
required: true,
trim: false,
@@ -723,31 +801,36 @@ export async function handleSlackAction(
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
destination,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
const result = await slackActionRuntime.sendSlackMessage(to, initialComment ?? "", {
...writeOpts,
mediaUrl: filePath,
mediaAccess: context?.mediaAccess,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(filename ? { uploadFileName: filename } : {}),
...(title ? { uploadTitle: title } : {}),
});
const result = await slackActionRuntime.sendSlackMessage(
destination,
initialComment ?? "",
{
...buildActionOpts("write"),
mediaUrl: filePath,
mediaAccess: context?.mediaAccess,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(filename ? { uploadFileName: filename } : {}),
...(title ? { uploadTitle: title } : {}),
},
);
if (context?.hasRepliedRef && slackContextTargetsMatch(to, context)) {
if (context?.hasRepliedRef && slackContextTargetsMatch(destination, context)) {
context.hasRepliedRef.value = true;
}
return jsonResult({ ok: true, result });
}
case "editMessage": {
const channelId = resolveChannelId();
const target = resolveChannelTarget();
const { channelId } = target;
const messageId = readStringParam(params, "messageId", {
required: true,
});
@@ -758,35 +841,30 @@ export async function handleSlackAction(
if (!content && !blocks) {
throw new Error("Slack editMessage requires content or blocks.");
}
await assertReadTargetAllowed(channelId);
if (writeOpts) {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
} else {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
blocks,
});
}
await assertReadTargetAllowed(target);
const writeOpts = buildActionOpts("write", target.teamId);
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
return jsonResult({ ok: true });
}
case "deleteMessage": {
const channelId = resolveChannelId();
const target = resolveChannelTarget();
const { channelId } = target;
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(channelId);
if (writeOpts) {
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.deleteSlackMessage(channelId, messageId);
}
await assertReadTargetAllowed(target);
const writeOpts = buildActionOpts("write", target.teamId);
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
case "readMessages": {
const channelId = resolveChannelId();
await assertReadTargetAllowed(channelId);
const target = resolveChannelTarget();
const { channelId } = target;
await assertReadTargetAllowed(target);
const readOpts = buildActionOpts("read", target.teamId);
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
@@ -827,16 +905,18 @@ export async function handleSlackAction(
"Slack file download requires channelId or to so the read target can be authorized.",
);
}
const channelId = resolveSlackChannelId(channelTarget);
await assertReadTargetAllowed(channelId);
const target = resolveSlackActionChannelTarget(account, channelTarget, context);
const { channelId } = target;
await assertReadTargetAllowed(target);
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const maxBytes = account.config?.mediaMaxMb
? account.config.mediaMaxMb * 1024 * 1024
: 20 * 1024 * 1024;
const readToken = resolveSlackOperationToken(account, "read");
const readOpts = buildActionOpts("read", target.teamId);
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
...readOpts,
...(readToken && !readOpts?.token ? { token: readToken } : {}),
...(readToken && !readOpts.token ? { token: readToken } : {}),
maxBytes,
channelId,
threadId: threadId ?? undefined,
@@ -882,35 +962,28 @@ export async function handleSlackAction(
if (!isActionEnabled("pins")) {
throw new Error("Slack pins are disabled.");
}
const channelId = resolveChannelId();
const target = resolveChannelTarget();
const { channelId } = target;
const readOpts = buildActionOpts("read", target.teamId);
const writeOpts = buildActionOpts("write", target.teamId);
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(channelId);
if (writeOpts) {
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.pinSlackMessage(channelId, messageId);
}
await assertReadTargetAllowed(target);
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(channelId);
if (writeOpts) {
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.unpinSlackMessage(channelId, messageId);
}
await assertReadTargetAllowed(target);
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
await assertReadTargetAllowed(channelId);
const pins = writeOpts
? await slackActionRuntime.listSlackPins(channelId, readOpts)
: await slackActionRuntime.listSlackPins(channelId);
await assertReadTargetAllowed(target);
const pins = await slackActionRuntime.listSlackPins(channelId, readOpts);
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
@@ -929,9 +1002,14 @@ export async function handleSlackAction(
}
const userId = readStringParam(params, "userId", { required: true });
assertSlackMemberInfoAllowed({ account, context, userId });
const info = readOpts
? await slackActionRuntime.getSlackMemberInfo(userId, readOpts)
: await slackActionRuntime.getSlackMemberInfo(userId);
const teamId = resolveTrustedCurrentSlackTeamId({ account, context });
if (account.config.enterpriseOrgInstall === true) {
assertSlackDirectSendAllowed(account, teamId);
}
const info = await slackActionRuntime.getSlackMemberInfo(
userId,
buildActionOpts("read", teamId),
);
return jsonResult({ ok: true, info });
}
@@ -942,9 +1020,11 @@ export async function handleSlackAction(
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const result = readOpts
? await slackActionRuntime.listSlackEmojis(readOpts)
: await slackActionRuntime.listSlackEmojis();
const teamId = resolveTrustedCurrentSlackTeamId({ account, context });
if (account.config.enterpriseOrgInstall === true) {
assertSlackDirectSendAllowed(account, teamId);
}
const result = await slackActionRuntime.listSlackEmojis(buildActionOpts("read", teamId));
if (limit != null && limit > 0 && result.emoji != null) {
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));
if (entries.length > limit) {
@@ -267,7 +267,9 @@ describe("downloadSlackFile", () => {
maxBytes: 1024,
});
expect(createSlackLookupClientMock).toHaveBeenCalledWith("xoxb-from-cfg");
expect(createSlackLookupClientMock).toHaveBeenCalledWith("xoxb-from-cfg", {
teamId: undefined,
});
expect(resolveSlackMedia).toHaveBeenCalledWith({
files: [
{
+29 -1
View File
@@ -1,8 +1,15 @@
// Slack tests cover actions.reactions plugin behavior.
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { reactSlackMessage, removeOwnSlackReactions, removeSlackReaction } from "./actions.js";
const getSlackWriteClientMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return { ...actual, getSlackWriteClient: getSlackWriteClientMock };
});
function createClient() {
return {
auth: {
@@ -39,6 +46,27 @@ function slackPlatformError(error: string) {
}
describe("reactSlackMessage", () => {
beforeEach(() => {
getSlackWriteClientMock.mockReset();
});
it("uses a workspace-scoped write client for Enterprise Grid reactions", async () => {
const client = createClient();
getSlackWriteClientMock.mockReturnValue(client);
await reactSlackMessage("C1", "123.456", "✅", {
teamId: "T1",
token: "xoxb-test",
});
expect(getSlackWriteClientMock).toHaveBeenCalledWith("xoxb-test", { teamId: "T1" });
expect(client.reactions.add).toHaveBeenCalledWith({
channel: "C1",
timestamp: "123.456",
name: "white_check_mark",
});
});
it("treats already_reacted as idempotent success", async () => {
const client = createClient();
client.reactions.add.mockRejectedValueOnce(slackPlatformError("already_reacted"));
+3 -1
View File
@@ -411,6 +411,8 @@ describe("Slack read actions", () => {
cfg: { channels: { slack: { enabled: true, botToken: "test-auth-token" } } },
} as Parameters<typeof resolveSlackConversationName>[1]);
expect(createSlackLookupClientMock).toHaveBeenCalledWith("test-auth-token");
expect(createSlackLookupClientMock).toHaveBeenCalledWith("test-auth-token", {
teamId: undefined,
});
});
});
+5 -1
View File
@@ -32,6 +32,7 @@ export type SlackActionClientOpts = {
cfg?: OpenClawConfig;
accountId?: string;
token?: string;
teamId?: string;
client?: WebClient;
};
@@ -214,7 +215,10 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
return opts.client;
}
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
return mode === "write" ? getSlackWriteClient(token) : createSlackLookupClient(token);
if (mode === "write") {
return getSlackWriteClient(token, { teamId: opts.teamId });
}
return createSlackLookupClient(token, { teamId: opts.teamId });
}
async function resolveBotUserId(client: WebClient) {
+15 -4
View File
@@ -34,7 +34,12 @@ import {
getSlackExecApprovalApprovers,
isSlackExecApprovalClientEnabled,
} from "./exec-approvals.js";
import { canonicalizeSlackApiTargetId, parseSlackTarget } from "./target-parsing.js";
import {
canonicalizeSlackApiTargetId,
formatSlackTarget,
parseSlackTarget,
type SlackTarget,
} from "./target-parsing.js";
export type SlackApprovalKind = "exec" | "plugin";
export type SlackNativeApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
@@ -131,7 +136,7 @@ export function resolveTurnSourceSlackOriginTarget(
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
to: formatSlackApprovalTarget(parsed),
threadId: stringifyRouteThreadId(request.request.turnSourceThreadId),
};
}
@@ -164,7 +169,7 @@ export function resolveSlackFallbackOriginTarget(
return null;
}
return {
to: `${parsed.kind}:${canonicalizeSlackApiTargetId(parsed.kind, parsed.id)}`,
to: formatSlackApprovalTarget(parsed, canonicalizeSlackApiTargetId(parsed.kind, parsed.id)),
threadId: sessionTarget.threadId,
};
}
@@ -232,7 +237,7 @@ export function normalizeSlackForwardTarget(
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
to: formatSlackApprovalTarget(parsed),
accountId: normalizeOptionalString(target.accountId),
threadId: stringifyRouteThreadId(target.threadId),
};
@@ -440,3 +445,9 @@ export function shouldHandleSlackNativeApprovalRequest(params: {
sessionFilter: config?.sessionFilter,
});
}
function formatSlackApprovalTarget(target: SlackTarget, id = target.id): string {
return target.teamId
? formatSlackTarget({ teamId: target.teamId, kind: target.kind, id })
: `${target.kind}:${id}`;
}
+1 -1
View File
@@ -767,7 +767,7 @@ describe("slack native approval adapter", () => {
);
expect(target).toEqual({
to: "channel:team:T123:channel:C08GQH53EJM",
to: "team:T123:channel:C08GQH53EJM",
threadId: undefined,
});
});
+36 -3
View File
@@ -9,7 +9,7 @@ import type { SlackActionContext } from "./action-runtime.js";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend } from "./message-actions.js";
import { describeSlackMessageTool } from "./message-tool-api.js";
import { resolveSlackChannelId } from "./targets.js";
import { formatSlackTarget, parseSlackTarget, resolveSlackChannelId } from "./target-parsing.js";
type SlackActionInvoke = (
action: Record<string, unknown>,
@@ -66,12 +66,15 @@ export function createSlackActions(
extractToolSend: ({ args }) => extractSlackToolSend(args),
isToolDeliveryAction: ({ args }) =>
typeof args.action === "string" && SLACK_TOOL_DELIVERY_ACTIONS.has(args.action),
prepareSendPayload: ({ ctx, payload }) => (ctx.action === "send" ? payload : null),
prepareSendPayload: ({ ctx, to, payload }) =>
ctx.action === "send" && !shouldUseWorkspaceAwareSlackActionSend(to, ctx.toolContext)
? payload
: null,
handleAction: async (ctx) => {
return await handleSlackMessageAction({
providerId,
ctx,
normalizeChannelId: resolveSlackChannelId,
normalizeChannelId: normalizeSlackActionChannelTarget,
includeReadThreadId: true,
invoke: async (action, cfg, toolContext) => {
const actionContext = resolveSlackActionContext(ctx, toolContext);
@@ -83,3 +86,33 @@ export function createSlackActions(
},
};
}
function normalizeSlackActionChannelTarget(raw: string): string {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
const channelId = resolveSlackChannelId(raw);
return formatSlackTarget({ teamId: target?.teamId, kind: "channel", id: channelId });
}
function shouldUseWorkspaceAwareSlackActionSend(
rawTarget: string,
context: ChannelMessageActionContext["toolContext"],
): boolean {
const target = parseSlackTarget(rawTarget, { defaultKind: "channel" });
if (!target || target.teamId) {
return false;
}
for (const rawCurrentTarget of [context?.currentChannelId, context?.currentMessagingTarget]) {
if (!rawCurrentTarget) {
continue;
}
const currentTarget = parseSlackTarget(rawCurrentTarget);
if (
currentTarget?.teamId &&
currentTarget.kind === target.kind &&
currentTarget.id.toLowerCase() === target.id.toLowerCase()
) {
return true;
}
}
return false;
}
+16 -8
View File
@@ -117,7 +117,7 @@ describe("resolveSlackChannelType", () => {
type: "dm",
user: "U09G2DJ0275",
});
expect(createSlackReadClientMock).toHaveBeenCalledWith("xoxb-test");
expect(createSlackReadClientMock).toHaveBeenCalledWith("xoxb-test", { teamId: undefined });
expect(createSlackWebClientMock).not.toHaveBeenCalled();
expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: "D0AEWSDHAQH" });
expect(conversationsOpenMock).not.toHaveBeenCalled();
@@ -149,7 +149,7 @@ describe("resolveSlackChannelType", () => {
type: "dm",
user: "U09G2DJ0275",
});
expect(createSlackWebClientMock).toHaveBeenCalledWith("botB");
expect(createSlackWebClientMock).toHaveBeenCalledWith("botB", { teamId: undefined });
expect(createSlackReadClientMock).not.toHaveBeenCalled();
expect(conversationsOpenMock).toHaveBeenCalledWith({
channel: "D0AEWSDHAQH",
@@ -185,7 +185,9 @@ describe("resolveSlackChannelType", () => {
type: "dm",
user: "U09G2DJ0275",
});
expect(createSlackWebClientMock).toHaveBeenCalledWith("test-user-token");
expect(createSlackWebClientMock).toHaveBeenCalledWith("test-user-token", {
teamId: undefined,
});
expect(createSlackReadClientMock).not.toHaveBeenCalled();
expect(conversationsOpenMock).toHaveBeenCalledWith({
channel: "D0AEWSDHAQH",
@@ -221,7 +223,7 @@ describe("resolveSlackChannelType", () => {
type: "dm",
user: "U09G2DJ0275",
});
expect(createSlackReadClientMock).toHaveBeenCalledWith("envUsr");
expect(createSlackReadClientMock).toHaveBeenCalledWith("envUsr", { teamId: undefined });
expect(createSlackWebClientMock).not.toHaveBeenCalled();
expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: "D0AEWSDHAQH" });
expect(conversationsOpenMock).not.toHaveBeenCalled();
@@ -253,7 +255,7 @@ describe("resolveSlackChannelType", () => {
type: "dm",
user: "U09G2DJ0275",
});
expect(createSlackWebClientMock).toHaveBeenCalledWith("envBot");
expect(createSlackWebClientMock).toHaveBeenCalledWith("envBot", { teamId: undefined });
expect(createSlackReadClientMock).not.toHaveBeenCalled();
expect(conversationsOpenMock).toHaveBeenCalledWith({
channel: "D0AEWSDHAQH",
@@ -289,7 +291,9 @@ describe("resolveSlackChannelType", () => {
type: "group",
name: "mpdm-alice--bob-1",
});
expect(createSlackReadClientMock).toHaveBeenCalledWith("xoxp-reader");
expect(createSlackReadClientMock).toHaveBeenCalledWith("xoxp-reader", {
teamId: undefined,
});
expect(createSlackWebClientMock).not.toHaveBeenCalled();
expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: "C0MPIM" });
});
@@ -334,8 +338,12 @@ describe("resolveSlackChannelType", () => {
}),
).resolves.toMatchObject({ name: "after-rotation" });
expect(createSlackReadClientMock).toHaveBeenNthCalledWith(1, "xoxb-before");
expect(createSlackReadClientMock).toHaveBeenNthCalledWith(2, "xoxb-after");
expect(createSlackReadClientMock).toHaveBeenNthCalledWith(1, "xoxb-before", {
teamId: undefined,
});
expect(createSlackReadClientMock).toHaveBeenNthCalledWith(2, "xoxb-after", {
teamId: undefined,
});
expect(conversationsInfoMock).toHaveBeenCalledTimes(2);
});
+6 -3
View File
@@ -74,6 +74,7 @@ export async function resolveSlackConversationInfo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
teamId?: string;
operation?: "read" | "write";
requireFreshName?: boolean;
}): Promise<SlackConversationInfo> {
@@ -87,7 +88,8 @@ export async function resolveSlackConversationInfo(params: {
const userToken = normalizeOptionalString(account.userToken);
const credentialRole = token ? (token === userToken ? "user" : "bot") : "none";
const credentialFingerprint = token ? fingerprintSlackCredential(token) : "none";
const cacheKey = `${account.accountId}:${operation}:${credentialRole}:${credentialFingerprint}:${channelId}`;
const teamId = normalizeLowercaseStringOrEmpty(params.teamId) || "no-team-id";
const cacheKey = `${account.accountId}:${teamId}:${operation}:${credentialRole}:${credentialFingerprint}:${channelId}`;
if (!params.requireFreshName) {
const cached = getCachedSlackConversationInfo(cacheKey);
if (cached) {
@@ -101,7 +103,7 @@ export async function resolveSlackConversationInfo(params: {
// Read-only classification stays on conversations.info. conversations.open is
// write-scoped and must only run when the caller explicitly requests a write.
if (isNativeImChannel && operation === "write") {
const client = createSlackWebClient(token);
const client = createSlackWebClient(token, { teamId: params.teamId });
const opened = await client.conversations.open({
channel: channelId,
prevent_creation: true,
@@ -117,7 +119,7 @@ export async function resolveSlackConversationInfo(params: {
}
return result;
}
const client = createSlackReadClient(token);
const client = createSlackReadClient(token, { teamId: params.teamId });
const info = await client.conversations.info({ channel: channelId });
const channel = info.channel as
| { is_im?: boolean; is_mpim?: boolean; name?: string; user?: string }
@@ -151,6 +153,7 @@ export async function resolveSlackChannelType(params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
teamId?: string;
}): Promise<"channel" | "group" | "dm" | "unknown"> {
return (await resolveSlackConversationInfo(params)).type;
}
+180 -8
View File
@@ -15,13 +15,17 @@ const { resolveSlackDmChannelIdMock, sendMessageSlackMock } = vi.hoisted(() => (
resolveSlackDmChannelIdMock: vi.fn(),
sendMessageSlackMock: vi.fn(),
}));
const { assistantThreadsSetStatusMock, conversationsInfoMock, conversationsOpenMock } = vi.hoisted(
() => ({
assistantThreadsSetStatusMock: vi.fn(),
conversationsInfoMock: vi.fn(),
conversationsOpenMock: vi.fn(),
}),
);
const {
assistantThreadsSetStatusMock,
conversationsInfoMock,
conversationsOpenMock,
getSlackWriteClientMock,
} = vi.hoisted(() => ({
assistantThreadsSetStatusMock: vi.fn(),
conversationsInfoMock: vi.fn(),
conversationsOpenMock: vi.fn(),
getSlackWriteClientMock: vi.fn(),
}));
vi.mock("./action-runtime.js", async () => {
const actual = await vi.importActual<typeof import("./action-runtime.js")>("./action-runtime.js");
@@ -52,7 +56,7 @@ vi.mock("./client.js", async () => {
return {
...actual,
createSlackReadClient: vi.fn(createClient),
createSlackWebClient: vi.fn(createClient),
getSlackWriteClient: getSlackWriteClientMock.mockImplementation(createClient),
};
});
@@ -66,6 +70,7 @@ beforeEach(async () => {
assistantThreadsSetStatusMock.mockResolvedValue({ ok: true });
conversationsInfoMock.mockReset();
conversationsOpenMock.mockReset();
getSlackWriteClientMock.mockClear();
setSlackRuntime({
channel: {
slack: {
@@ -96,6 +101,14 @@ function requireSlackHandleAction() {
return handleAction;
}
function requireSlackPrepareSendPayload() {
const prepareSendPayload = slackPlugin.actions?.prepareSendPayload;
if (!prepareSendPayload) {
throw new Error("slack actions.prepareSendPayload unavailable");
}
return prepareSendPayload;
}
function requireSlackSendText() {
const sendText = slackPlugin.outbound?.sendText;
if (!sendText) {
@@ -202,6 +215,45 @@ function findSchemaEntry(
}
describe("slackPlugin actions", () => {
it("keeps a bare current Grid send on the workspace-aware Slack action path", async () => {
const prepareSendPayload = requireSlackPrepareSendPayload();
const payload = { text: "hello" };
const prepared = await prepareSendPayload({
ctx: {
action: "send",
channel: "slack",
cfg: {},
params: {},
toolContext: {
currentChannelId: "team:T123:channel:C123",
currentChannelProvider: "slack",
},
},
to: "channel:C123",
payload,
} as never);
expect(prepared).toBeNull();
});
it("keeps qualified and cross-channel sends on the core Slack delivery path", async () => {
const prepareSendPayload = requireSlackPrepareSendPayload();
const payload = { text: "hello" };
const ctx = {
action: "send",
channel: "slack",
cfg: {},
params: {},
toolContext: { currentChannelId: "team:T123:channel:C123" },
};
expect(prepareSendPayload({ ctx, to: "team:T123:channel:C123", payload } as never)).toBe(
payload,
);
expect(prepareSendPayload({ ctx, to: "channel:C999", payload } as never)).toBe(payload);
});
it("prefers session lookup for announce target routing", () => {
expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true);
});
@@ -682,6 +734,34 @@ describe("slackPlugin status", () => {
});
});
it("matches the workspace-qualified session identity produced by Enterprise ingress", async () => {
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
if (!resolveRoute) {
throw new Error("slack messaging.resolveOutboundSessionRoute unavailable");
}
const channelRoute = await resolveRoute({
cfg: {} as OpenClawConfig,
agentId: "main",
target: "team:T123:channel:C456",
});
const dmRoute = await resolveRoute({
cfg: {} as OpenClawConfig,
agentId: "main",
accountId: "default",
target: "team:T123:user:U456",
});
expectRecordFields(channelRoute, "Enterprise Slack channel route", {
baseSessionKey: "agent:main:slack:channel:team:t123:channel:c456",
to: "team:T123:channel:C456",
});
expectRecordFields(dmRoute, "Enterprise Slack DM route", {
baseSessionKey: "agent:main:main:account:default:team:t123",
to: "team:T123:user:U456",
});
});
it("routes a folded bare W user id as a direct session", async () => {
const resolveRoute = slackPlugin.messaging?.resolveOutboundSessionRoute;
if (!resolveRoute) {
@@ -1009,6 +1089,62 @@ describe("slackPlugin outbound", () => {
expect(sendSlack).not.toHaveBeenCalled();
});
it("sends Enterprise messages when the existing target carries the workspace", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-enterprise" });
const sendText = requireSlackSendText();
const result = await sendText({
cfg: { channels: { slack: { enterpriseOrgInstall: true } } },
to: "team:T123:channel:C456",
text: "hello",
accountId: "default",
deps: { sendSlack },
});
expect(requireMockCallArgValue(sendSlack, 0, 0)).toBe("team:T123:channel:C456");
expect(result).toEqual({ channel: "slack", messageId: "m-enterprise" });
});
it("rejects workspace-qualified targets for ordinary Slack installations", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "should-not-send" });
await expect(
requireSlackSendText()({
cfg,
to: "team:T123:channel:C456",
text: "hello",
accountId: "default",
deps: { sendSlack },
}),
).rejects.toThrow("unexpected_enterprise_slack_workspace");
expect(sendSlack).not.toHaveBeenCalled();
});
it("admits deferred Enterprise messages only when their target carries the workspace", () => {
const admit = slackPlugin.message?.durableFinal?.admitDeferredDelivery;
if (!admit) {
throw new Error("slack deferred-delivery admission unavailable");
}
const enterpriseCfg = {
channels: { slack: { enterpriseOrgInstall: true } },
} as OpenClawConfig;
const base = {
cfg: enterpriseCfg,
accountId: "default",
kind: "text" as const,
queueId: "q1",
payloads: [{ text: "hello" }],
};
expect(admit({ ...base, to: "team:T123:channel:C456" } as never)).toEqual({
status: "allowed",
});
expect(admit({ ...base, to: "channel:C456" } as never)).toEqual({
status: "permanent_rejection",
reason: "unsupported_enterprise_slack_delivery",
});
});
it("forwards agent identity through the registered text sender", async () => {
const sendText = requireSlackSendText();
@@ -1145,6 +1281,21 @@ describe("slackPlugin outbound", () => {
});
});
it("uses the workspace-partitioned write-client cache for Grid assistant status", async () => {
const target = {
cfg: { channels: { slack: { botToken: "xoxb-test", enterpriseOrgInstall: true } } },
to: "team:T123:channel:C456",
accountId: "default",
threadId: "1712345678.123456",
};
await requireSlackHeartbeatSendTyping()(target);
await requireSlackHeartbeatClearTyping()(target);
expect(getSlackWriteClientMock).toHaveBeenNthCalledWith(1, "xoxb-test", { teamId: "T123" });
expect(getSlackWriteClientMock).toHaveBeenNthCalledWith(2, "xoxb-test", { teamId: "T123" });
});
it("resolves user targets to concrete DM channels for assistant status", async () => {
await requireSlackHeartbeatSendTyping()({
cfg,
@@ -1363,6 +1514,27 @@ describe("slackPlugin outbound", () => {
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
it("preserves workspace-qualified Enterprise media delivery", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-grid-media" });
const sendMedia = requireSlackSendMedia();
const result = await sendMedia({
cfg: { channels: { slack: { enterpriseOrgInstall: true } } },
to: "team:T123:channel:C999",
text: "attachment",
mediaUrl: "/tmp/workspace/report.txt",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
deps: { sendSlack },
});
expect(requireMockCallArgValue(sendSlack, 0, 0)).toBe("team:T123:channel:C999");
expectRecordFields(requireMockCallArg(sendSlack, 0, 2), "send options", {
mediaUrl: "/tmp/workspace/report.txt",
});
expect(result).toEqual({ channel: "slack", messageId: "m-grid-media" });
});
it("sends block payload media first, then the final block message", async () => {
const sendSlack = vi
.fn()
+51 -21
View File
@@ -56,7 +56,7 @@ import {
type OpenClawConfig,
} from "./channel-api.js";
import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js";
import { createSlackWebClient } from "./client.js";
import { getSlackWriteClient } from "./client.js";
import { assertSlackDirectSendAllowed } from "./direct-send-admission.js";
import { formatSlackError } from "./errors.js";
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
@@ -74,7 +74,11 @@ import {
SLACK_CHANNEL,
slackConfigAdapter,
} from "./shared.js";
import { canonicalizeSlackApiTargetId, parseSlackTarget } from "./target-parsing.js";
import {
canonicalizeSlackApiTargetId,
formatSlackTarget,
parseSlackTarget,
} from "./target-parsing.js";
import { slackContextTargetsMatch } from "./targets.js";
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
@@ -182,6 +186,7 @@ const loadSlackDirectoryLiveModule = createLazyRuntimeModule(() => import("./dir
async function resolveSlackSendContext(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string;
to: string;
deps?: { [channelId: string]: unknown };
replyToId?: string | number | null;
threadId?: string | number | null;
@@ -190,7 +195,9 @@ async function resolveSlackSendContext(params: {
// 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 });
assertSlackDirectSendAllowed(account);
const target = parseSlackTarget(params.to, { defaultKind: "channel" });
const teamId = target?.teamId;
assertSlackDirectSendAllowed(account, teamId);
const send =
resolveOutboundSendDep<SlackSendFn>(params.deps, "slack") ??
(await loadSlackSendRuntime()).sendMessageSlack;
@@ -198,7 +205,7 @@ async function resolveSlackSendContext(params: {
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = resolveSlackThreadTsValue(params);
return { send, threadTsValue, tokenOverride };
return { send, threadTsValue, tokenOverride, to: params.to };
}
async function setSlackHeartbeatThreadStatus(params: {
@@ -214,13 +221,13 @@ async function setSlackHeartbeatThreadStatus(params: {
return;
}
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
assertSlackDirectSendAllowed(account);
assertSlackDirectSendAllowed(account, target.teamId);
const botToken = normalizeOptionalString(account.botToken);
if (!botToken) {
return;
}
try {
const client = createSlackWebClient(botToken);
const client = getSlackWriteClient(botToken, { teamId: target.teamId });
const apiTargetId = canonicalizeSlackApiTargetId(target.kind, target.id, params.to);
const channelId =
target.kind === "channel"
@@ -275,7 +282,7 @@ function resolveSlackRouteTarget(raw: string) {
return null;
}
return {
to: target.id,
to: target.teamId ? target.normalized : target.id,
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
};
}
@@ -357,7 +364,7 @@ async function resolveSlackOutboundSessionRoute(params: {
const apiTargetId = canonicalizeSlackApiTargetId(parsed.kind, parsed.id, params.target);
const isDm = parsed.kind === "user";
let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel";
let peerId = parsed.id;
let peerId = formatSlackTarget(parsed);
let recipientSessionExact = isDm
? /^[UW][A-Z0-9]{8,}$/i.test(parsed.id)
: /^C[A-Z0-9]{8,}$/i.test(parsed.id);
@@ -366,18 +373,24 @@ async function resolveSlackOutboundSessionRoute(params: {
cfg: params.cfg,
accountId: params.accountId,
channelId: apiTargetId,
teamId: parsed.teamId,
});
if (conversation.type !== "dm" || !conversation.user) {
return null;
}
peerKind = "direct";
peerId = conversation.user;
peerId = formatSlackTarget({
teamId: parsed.teamId,
kind: "user",
id: conversation.user,
});
recipientSessionExact = true;
} else if (!isDm && /^G/i.test(parsed.id)) {
const channelType = await resolveSlackChannelType({
cfg: params.cfg,
accountId: params.accountId,
channelId: apiTargetId,
teamId: parsed.teamId,
});
if (channelType === "group") {
peerKind = "group";
@@ -391,12 +404,18 @@ async function resolveSlackOutboundSessionRoute(params: {
kind: peerKind,
id: peerId,
};
const baseSessionKey = buildSlackBaseSessionKey({
const unpartitionedBaseSessionKey = buildSlackBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer,
});
const baseSessionKey =
parsed.teamId && peerKind === "direct" && (params.cfg.session?.dmScope ?? "main") === "main"
? `${unpartitionedBaseSessionKey}:account:${encodeURIComponent(
resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }).accountId,
).toLowerCase()}:team:${encodeURIComponent(parsed.teamId).toLowerCase()}`
: unpartitionedBaseSessionKey;
return buildThreadAwareOutboundSessionRoute({
route: {
sessionKey: baseSessionKey,
@@ -410,7 +429,7 @@ async function resolveSlackOutboundSessionRoute(params: {
: peerKind === "group"
? `slack:group:${peerId}`
: `slack:channel:${peerId}`,
to: peerKind === "direct" ? `user:${peerId}` : `channel:${peerId}`,
to: parsed.teamId ? peerId : peerKind === "direct" ? `user:${peerId}` : `channel:${peerId}`,
},
replyToId: params.replyToId,
threadId: params.threadId,
@@ -498,9 +517,10 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
},
}),
sendPayload: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
const { send, threadTsValue, tokenOverride, to } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
to: ctx.to,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
@@ -508,6 +528,7 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendPayload!({
...ctx,
to,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
@@ -520,9 +541,10 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
});
},
sendText: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
const { send, threadTsValue, tokenOverride, to } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
to: ctx.to,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
@@ -530,6 +552,7 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendText!({
...ctx,
to,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
@@ -544,9 +567,10 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
});
},
sendMedia: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
const { send, threadTsValue, tokenOverride, to } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
to: ctx.to,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
@@ -554,6 +578,7 @@ const slackChannelOutbound: ChannelOutboundAdapter = {
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendMedia!({
...ctx,
to,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
@@ -595,15 +620,20 @@ const slackMessageAdapter = {
...slackMessageAdapterBase.durableFinal?.capabilities,
reconcileUnknownSend: true,
},
admitDeferredDelivery: ({ cfg, accountId }) => {
admitDeferredDelivery: ({ cfg, accountId, to }) => {
const effectiveAccountId =
normalizeOptionalString(accountId) ?? resolveDefaultSlackAccountId(cfg);
return mergeSlackAccountConfig(cfg, effectiveAccountId).enterpriseOrgInstall === true
? {
status: "permanent_rejection" as const,
reason: "unsupported_enterprise_slack_delivery",
}
: { status: "allowed" as const };
const account = resolveSlackAccount({ cfg, accountId: effectiveAccountId });
try {
const target = parseSlackTarget(to, { defaultKind: "channel" });
assertSlackDirectSendAllowed(account, target?.teamId);
return { status: "allowed" as const };
} catch (error) {
return {
status: "permanent_rejection" as const,
reason: error instanceof Error ? error.message : String(error),
};
}
},
reconcileUnknownSendKinds: { text: true },
reconcileUnknownSend: async (ctx) =>
+4 -1
View File
@@ -20,7 +20,10 @@ const requireFromSlackSocketMode = (() => {
function loadSlackUndiciRuntime(): SlackUndiciRuntime {
return requireFromSlackSocketMode("undici") as SlackUndiciRuntime;
}
export type SlackLookupClientOptions = Pick<WebClientOptions, "fetch" | "slackApiUrl" | "timeout">;
export type SlackLookupClientOptions = Pick<
WebClientOptions,
"fetch" | "slackApiUrl" | "teamId" | "timeout"
>;
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
+25 -2
View File
@@ -341,12 +341,35 @@ describe("slack web client config", () => {
expect(WebClient).toHaveBeenCalledTimes(2);
});
it("only exposes API-root options on cached write clients", () => {
it("limits cached write-client options to routing scopes", () => {
expectTypeOf<NonNullable<Parameters<typeof getSlackWriteClient>[1]>>().toEqualTypeOf<
Pick<WebClientOptions, "slackApiUrl">
Pick<WebClientOptions, "slackApiUrl" | "teamId">
>();
});
it("keeps one org token partitioned by workspace", () => {
clearProxyEnvForTest();
try {
const first = getSlackWriteClient("xoxb-org", { teamId: "T1" });
const reused = getSlackWriteClient("xoxb-org", { teamId: "T1" });
const second = getSlackWriteClient("xoxb-org", { teamId: "T2" });
expect(reused).toBe(first);
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
expect(WebClient).toHaveBeenNthCalledWith(1, "xoxb-org", {
retryConfig: SLACK_WRITE_RETRY_OPTIONS,
teamId: "T1",
});
expect(WebClient).toHaveBeenNthCalledWith(2, "xoxb-org", {
retryConfig: SLACK_WRITE_RETRY_OPTIONS,
teamId: "T2",
});
} finally {
restoreProxyEnvForTest();
}
});
it("keeps write clients separated by Slack API URL client options", () => {
clearProxyEnvForTest();
try {
+4 -2
View File
@@ -20,7 +20,7 @@ let slackListenerUploadCompletionClientCache = new WeakMap<
{ teamId: string; client: WebClient }
>();
type SlackWriteClientCacheOptions = Pick<WebClientOptions, "slackApiUrl">;
type SlackWriteClientCacheOptions = Pick<WebClientOptions, "slackApiUrl" | "teamId">;
type SlackFetch = NonNullable<WebClientOptions["fetch"]>;
export {
@@ -92,7 +92,9 @@ export function createSlackTokenCacheKey(token: string): string {
function slackWriteClientCacheKey(token: string, options: SlackWriteClientCacheOptions): string {
const tokenKey = createSlackTokenCacheKey(token);
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
const apiScope = options.slackApiUrl ? `:api:${options.slackApiUrl}` : "";
const teamScope = options.teamId ? `:team:${options.teamId.trim().toLowerCase()}` : "";
return `${tokenKey}${apiScope}${teamScope}`;
}
export function getSlackWriteClient(
@@ -1,8 +1,12 @@
// Slack plugin module owns admission for exported/direct delivery paths.
import type { ResolvedSlackAccount } from "./accounts.js";
export function assertSlackDirectSendAllowed(account: ResolvedSlackAccount): void {
if (account.config.enterpriseOrgInstall === true) {
export function assertSlackDirectSendAllowed(account: ResolvedSlackAccount, teamId?: string): void {
const hasTeamScope = Boolean(teamId?.trim());
if (account.config.enterpriseOrgInstall === true && !hasTeamScope) {
throw new Error("unsupported_enterprise_slack_delivery");
}
if (account.config.enterpriseOrgInstall !== true && hasTeamScope) {
throw new Error("unexpected_enterprise_slack_workspace");
}
}
+1 -7
View File
@@ -83,9 +83,6 @@ describe("createSlackDraftStream", () => {
it("uses the enterprise event client for draft writes", async () => {
const client = {} as NonNullable<DraftStreamParams["eventScope"]>["client"];
const eventScope = {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true as const,
teamId: "T_TEST",
client,
};
@@ -100,7 +97,7 @@ describe("createSlackDraftStream", () => {
expect(send).toHaveBeenCalledWith(
"channel:C123",
"hello",
expect.objectContaining({ client, enterpriseEventScope: eventScope }),
expect.objectContaining({ eventScope }),
);
expect(edit).toHaveBeenCalledWith(
"C123",
@@ -353,9 +350,6 @@ describe("createSlackDraftStream", () => {
it("keeps simultaneous Enterprise Grid conversations isolated by workspace", async () => {
const accountId = "enterprise-grid";
const eventScope = {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true as const,
teamId: "T_FIRST",
client: {} as NonNullable<DraftStreamParams["eventScope"]>["client"],
};
+1 -3
View File
@@ -117,9 +117,7 @@ export function createSlackDraftStream(params: {
accountId: params.accountId,
threadTs,
identity: params.identity,
...(params.eventScope
? { client: params.eventScope.client, enterpriseEventScope: params.eventScope }
: {}),
...(params.eventScope ? { eventScope: params.eventScope } : {}),
...(params.metadata ? { metadata: params.metadata } : {}),
...(blocks ? { blocks } : {}),
});
@@ -61,6 +61,61 @@ describe("Slack message tools", () => {
);
});
it("preserves a workspace-qualified channel for reactions", async () => {
const invoke = vi.fn(async () => ({ content: [], details: { ok: true } }));
const actions = createSlackActions("slack", { invoke });
if (!actions.handleAction) {
throw new Error("Slack message actions must provide an executor.");
}
await actions.handleAction({
channel: "slack",
action: "react",
cfg: {} as OpenClawConfig,
params: {
channelId: "team:T123:channel:C123",
messageId: "123.456",
emoji: "✅",
},
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "react",
channelId: "team:T123:channel:C123",
}),
expect.any(Object),
undefined,
);
});
it("preserves a workspace-qualified channel for implicit upload destinations", async () => {
const invoke = vi.fn(async () => ({ content: [], details: { ok: true } }));
const actions = createSlackActions("slack", { invoke });
if (!actions.handleAction) {
throw new Error("Slack message actions must provide an executor.");
}
await actions.handleAction({
channel: "slack",
action: "upload-file",
cfg: {} as OpenClawConfig,
params: {
channelId: "team:T123:channel:C123",
filePath: "/tmp/report.png",
},
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "uploadFile",
to: "team:T123:channel:C123",
}),
expect.any(Object),
undefined,
);
});
it("classifies provider-native mutation actions", () => {
const actions = createSlackActions("slack");
for (const action of ["sendMessage", "editMessage", "deleteMessage", "pinMessage"]) {
@@ -172,10 +172,7 @@ describe("createSlackMonitorContext channel metadata cache", () => {
it("isolates remembered types by enterprise team scope", async () => {
const createScope = (teamId: string): SlackEventScope =>
({
apiAppId: "A_EXPECTED",
enterpriseId: "E_EXPECTED",
teamId,
isEnterpriseInstall: true,
client: {
conversations: { info: vi.fn().mockRejectedValue(new Error("missing_scope")) },
},
@@ -17,10 +17,7 @@ describe("resolveSlackEventScope", () => {
expect(result).toMatchObject({
ok: true,
scope: {
apiAppId: "A123",
enterpriseId: "E123",
teamId,
isEnterpriseInstall: true,
client: listenerClient,
},
});
@@ -39,10 +36,7 @@ describe("resolveSlackEventScope", () => {
expect(result).toMatchObject({
ok: true,
scope: {
apiAppId: "A123",
enterpriseId: "E123",
teamId: "T111",
isEnterpriseInstall: true,
client,
},
});
+2 -8
View File
@@ -4,17 +4,14 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import { getSlackListenerUploadCompletionClient } from "../client.js";
import type { SlackInstallationIdentity } from "./enterprise-install.js";
export type SlackEventScope = {
apiAppId: string;
enterpriseId: string;
export type SlackEventScope = Readonly<{
teamId: string;
isEnterpriseInstall: true;
// Keep Bolt's exact listener client for ordinary reads and writes.
client: WebClient;
// Completion is one-shot, so uploads finalize through a team-scoped client
// that cannot inherit Bolt's normal request retries.
uploadCompletionClient?: WebClient;
};
}>;
type SlackEventScopeResolution =
| { ok: true; scope?: SlackEventScope }
@@ -86,10 +83,7 @@ export function resolveSlackEventScope(params: {
return {
ok: true,
scope: {
apiAppId,
enterpriseId,
teamId,
isEnterpriseInstall: true,
client: params.client,
...(uploadCompletionClient ? { uploadCompletionClient } : {}),
},
@@ -83,7 +83,7 @@ export async function createSlackDispatchSetup(prepared: PreparedSlackMessage) {
sessionKey: inboundLastRouteSessionKey,
deliveryContext: {
channel: "slack",
to: `user:${message.user}`,
to: prepared.ctxPayload.OriginatingTo ?? prepared.ctxPayload.To ?? `user:${message.user}`,
accountId: route.accountId,
threadId: prepared.ctxPayload.MessageThreadId ?? prepared.ctxPayload.TransportThreadId,
},
@@ -321,9 +321,6 @@ function createPreparedSlackMessage(params?: {
relayIdentity?: { username?: string; iconUrl?: string; iconEmoji?: string };
turnAdoptionLifecycle?: object;
eventScope?: {
apiAppId: string;
enterpriseId: string;
isEnterpriseInstall: true;
teamId: string;
client: Record<string, unknown>;
};
@@ -405,9 +402,6 @@ async function dispatchNativeProgressScenario(params: {
};
replyToMode?: "off" | "first" | "all" | "batched";
eventScope?: {
apiAppId: string;
enterpriseId: string;
isEnterpriseInstall: true;
teamId: string;
client: Record<string, unknown>;
};
@@ -1458,6 +1452,29 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
});
});
it("preserves a workspace-qualified DM route during dispatch", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
isDirectMessage: true,
message: {
channel: "D123",
user: "U1",
ts: "501.000",
},
ctxPayload: {
OriginatingTo: "team:T123:user:U1",
SessionKey: "agent:main:main:account:default:team:t123",
},
}),
);
expect(updateLastRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
deliveryContext: expect.objectContaining({ to: "team:T123:user:U1" }),
}),
);
});
it("uses DM transport thread metadata for last-route updates", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
@@ -3144,9 +3161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
finalPayload: { text: FINAL_REPLY_TEXT },
events: [{ kind: "item", progressText: "checking" }],
eventScope: {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true,
teamId: "T_ENTERPRISE",
client: eventClient,
},
@@ -3992,9 +4006,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
chat: { postMessage: postMessageMock, update: chatUpdateMock },
};
const eventScope = {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true as const,
teamId: "T_ENTERPRISE",
client: eventClient,
};
@@ -4263,9 +4274,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
eventScope: {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true,
teamId: "T_ENTERPRISE",
client: eventClient,
},
@@ -4286,9 +4294,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
users: { info: usersInfo },
};
const eventScope = {
apiAppId: "A_TEST",
enterpriseId: "E_TEST",
isEnterpriseInstall: true as const,
teamId: "T_ENTERPRISE",
client: eventClient,
};
@@ -586,7 +586,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (anyReplyDelivered && participationThreadTs) {
recordSlackThreadParticipation(account.accountId, message.channel, participationThreadTs, {
agentId: route.agentId,
...(prepared.eventScope ? { teamId: prepared.eventScope.teamId } : {}),
teamId: prepared.eventScope?.teamId,
});
}
if (dispatchError) {
@@ -445,14 +445,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared.ctxPayload.From).toBe("slack:U123");
});
it("uses the validated event workspace as the standardized conversation space", async () => {
it("carries the validated event workspace through reusable DM routing", async () => {
const ctx = createDefaultSlackCtx();
ctx.teamId = "";
const eventScope = {
apiAppId: "A1",
enterpriseId: "E1",
isEnterpriseInstall: true,
teamId: "T_ENTERPRISE",
teamId: "T123ENTERPRISE",
client: {} as SlackEventScope["client"],
} satisfies SlackEventScope;
@@ -464,7 +461,53 @@ describe("slack prepareSlackMessage inbound contract", () => {
});
assertPrepared(prepared, "org-wide Slack DM");
expect(prepared.ctxPayload.GroupSpace).toBe("T_ENTERPRISE");
expect(prepared.ctxPayload.GroupSpace).toBe("T123ENTERPRISE");
expect(prepared.ctxPayload.To).toBe("team:T123ENTERPRISE:user:U123");
expect(prepared.ctxPayload.OriginatingTo).toBe("team:T123ENTERPRISE:user:U123");
expect(prepared.ctxPayload.NativeChannelId).toBe("D999");
expect(prepared.replyTarget).toBe("channel:D999");
expect(prepared.turn.record).toMatchObject({
updateLastRoute: {
channel: "slack",
to: "team:T123ENTERPRISE:user:U123",
},
});
});
it("carries the validated event workspace through reusable channel routing", async () => {
const ctx = createReplyToAllSlackCtx({
groupPolicy: "open",
defaultRequireMention: false,
asChannel: true,
});
const eventScope = {
teamId: "T123ENTERPRISE",
client: {} as SlackEventScope["client"],
} satisfies SlackEventScope;
const prepared = await prepareSlackMessage({
ctx,
account: createSlackAccount({ groupPolicy: "open" }),
message: createSlackMessage({
channel: "C123CHANNEL",
channel_type: "channel",
user: "U123",
text: "hello",
}),
opts: { source: "message", eventScope },
});
assertPrepared(prepared, "org-wide Slack channel message");
expect(prepared.ctxPayload.To).toBe("team:T123ENTERPRISE:channel:C123CHANNEL");
expect(prepared.ctxPayload.OriginatingTo).toBe("team:T123ENTERPRISE:channel:C123CHANNEL");
expect(prepared.ctxPayload.NativeChannelId).toBe("C123CHANNEL");
expect(prepared.replyTarget).toBe("channel:C123CHANNEL");
expect(prepared.turn.record).toMatchObject({
updateLastRoute: {
channel: "slack",
to: "team:T123ENTERPRISE:channel:C123CHANNEL",
},
});
});
it("routes a self-threaded Agent View root before capability detection completes", async () => {
@@ -1026,6 +1069,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
source: "app_mention" | "message";
mentionType: "explicit" | "implicit" | "regex";
bindingOwner: "none" | "plugin" | "runtime";
enterpriseTeamId?: string;
expectRootMentioned?: boolean;
expectFollowUpMentioned?: boolean;
};
@@ -1046,7 +1090,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
const expectedSessionKey =
scenario.bindingOwner === "runtime"
? "agent:review:slack:channel:c0ahzfcas1k"
: `agent:main:slack:channel:${channelId.toLowerCase()}:thread:${rootTs}`;
: `agent:main:slack:channel:${scenario.enterpriseTeamId ? `team:${scenario.enterpriseTeamId.toLowerCase()}:channel:` : ""}${channelId.toLowerCase()}:thread:${rootTs}`;
const { storePath } = storeFixture.makeTmpStorePath();
const channelsConfig = implicit
? { [channelId]: { enabled: true, requireMention: false } }
@@ -1086,6 +1130,9 @@ describe("slack prepareSlackMessage inbound contract", () => {
replyToMode,
...(channelsConfig ? { channelsConfig } : {}),
});
if (scenario.enterpriseTeamId) {
Object.assign(slackCtx, { botUserId: "" });
}
slackCtx.resolveChannelName = async () => ({
name: implicit ? "genai" : "proj-openclaw",
type: "channel",
@@ -1148,9 +1195,19 @@ describe("slack prepareSlackMessage inbound contract", () => {
opts: {
source: scenario.source,
...(scenario.source === "app_mention" ? { wasMentioned: true } : {}),
...(scenario.enterpriseTeamId
? {
eventScope: {
teamId: scenario.enterpriseTeamId,
client: slackCtx.app.client,
},
}
: {}),
},
});
recordSlackThreadParticipation("default", channelId, rootTs);
recordSlackThreadParticipation("default", channelId, rootTs, {
teamId: scenario.enterpriseTeamId,
});
const followUp = await prepareSlackMessage({
ctx: slackCtx,
account,
@@ -1163,7 +1220,17 @@ describe("slack prepareSlackMessage inbound contract", () => {
ts: "1777244714.000100",
thread_ts: rootTs,
} as SlackMessageEvent,
opts: { source: "message" },
opts: {
source: "message",
...(scenario.enterpriseTeamId
? {
eventScope: {
teamId: scenario.enterpriseTeamId,
client: slackCtx.app.client,
},
}
: {}),
},
});
const expectedAgentId =
scenario.bindingOwner === "runtime"
@@ -3630,6 +3697,14 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
bindingOwner: "none",
expectFollowUpMentioned: true,
},
{
name: "keeps a Grid root app mention and unmentioned thread follow-up on one parent session",
source: "app_mention",
mentionType: "explicit",
bindingOwner: "none",
enterpriseTeamId: "T123ENTERPRISE",
expectFollowUpMentioned: true,
},
{
name: "keeps a message-first root mention and URL-only Slack thread follow-up on one parent session",
source: "message",
@@ -70,9 +70,6 @@ function buildChannelMessage(overrides?: Partial<SlackMessageEvent>): SlackMessa
function buildEventScope(teamId: string): SlackEventScope {
return {
apiAppId: "A1",
enterpriseId: "E1",
isEnterpriseInstall: true,
teamId,
client: {} as SlackEventScope["client"],
};
@@ -46,6 +46,7 @@ import { formatSlackError } from "../../errors.js";
import { formatSlackFileReference } from "../../file-reference.js";
import type { SlackSendIdentity } from "../../send.js";
import { hasSlackThreadParticipationWithPersistence } from "../../sent-thread-cache.js";
import { formatSlackTarget } from "../../target-parsing.js";
import type { SlackAttachment, SlackFile, SlackMessageEvent } from "../../types.js";
import { normalizeAllowListLower, normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import {
@@ -975,10 +976,10 @@ export async function prepareSlackMessage(params: {
)
: Promise.resolve({ ok: true, name: undefined });
let implicitMentionKinds: ReturnType<typeof implicitMentionKindWhen> = [];
if (!isDirectMessage && ctx.botUserId && message.thread_ts && !wasMentioned) {
if (!isDirectMessage && message.thread_ts && !wasMentioned) {
const replyToBotKinds = implicitMentionKindWhen(
"reply_to_bot",
message.parent_user_id === ctx.botUserId,
Boolean(ctx.botUserId && message.parent_user_id === ctx.botUserId),
);
implicitMentionKinds =
replyToBotKinds.length > 0
@@ -989,7 +990,7 @@ export async function prepareSlackMessage(params: {
accountId: account.accountId,
channelId: message.channel,
threadTs: message.thread_ts,
...(opts.eventScope ? { teamId: opts.eventScope.teamId } : {}),
teamId: opts.eventScope?.teamId,
}),
);
}
@@ -1182,6 +1183,22 @@ export async function prepareSlackMessage(params: {
channel: "slack",
accountId: account.accountId,
});
const preflightChannelTarget = opts.eventScope
? formatSlackTarget({
teamId: opts.eventScope.teamId,
kind: "channel",
id: message.channel,
})
: `channel:${message.channel}`;
const replyRouteTarget = opts.eventScope
? formatSlackTarget({
teamId: opts.eventScope.teamId,
kind: isDirectMessage ? "user" : "channel",
id: isDirectMessage ? senderId : message.channel,
})
: isDirectMessage
? `user:${message.user}`
: `channel:${message.channel}`;
const commandAuthorized = messageIngress.commandAccess.authorized;
if (isRoomish && messageIngress.commandAccess.shouldBlockControlCommand) {
@@ -1246,7 +1263,7 @@ export async function prepareSlackMessage(params: {
media: preflightMedia,
cfg,
accountId: account.accountId,
originatingTo: `channel:${message.channel}`,
originatingTo: preflightChannelTarget,
sessionKey: preflightRouting.sessionKey,
messageThreadId: preflightRouting.threadContext.messageThreadId,
})
@@ -1568,8 +1585,6 @@ export async function prepareSlackMessage(params: {
});
}
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
const { channelMetadata, groupSystemPrompt } = resolveSlackRoomContextHints({
isRoomish,
channelInfo,
@@ -1652,7 +1667,7 @@ export async function prepareSlackMessage(params: {
parentSessionKey: threadKeys.parentSessionKey,
},
reply: {
to: slackTo,
to: replyRouteTarget,
replyToId: threadContext.replyToId,
messageThreadId: directThreadRoutedToDmSession ? undefined : effectiveMessageThreadId,
nativeChannelId: message.channel,
@@ -1762,7 +1777,7 @@ export async function prepareSlackMessage(params: {
// received on. This avoids depending on a follow-up conversations.open
// round-trip for the normal reply path while keeping persisted routing
// metadata user-scoped for later session deliveries.
const replyTarget = isDirectMessage ? `channel:${message.channel}` : (ctxPayload.To ?? undefined);
const replyTarget = `channel:${message.channel}`;
if (!replyTarget) {
return null;
}
@@ -1772,7 +1787,7 @@ export async function prepareSlackMessage(params: {
transcript: preflightAudioTranscript,
cfg,
accountId: account.accountId,
originatingTo: `channel:${message.channel}`,
originatingTo: preflightChannelTarget,
messageThreadId: threadContext.messageThreadId,
});
}
@@ -1798,35 +1813,37 @@ export async function prepareSlackMessage(params: {
turn: {
storePath,
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: updateLastRouteSessionKey,
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: effectiveMessageThreadId,
mainDmOwnerPin:
updateLastRouteSessionKey === route.mainSessionKey &&
pinnedMainDmOwner &&
message.user
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: normalizeLowercaseStringOrEmpty(message.user),
onSkip: ({
ownerRecipient,
senderRecipient,
}: {
ownerRecipient: string;
senderRecipient: string;
}) => {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
updateLastRoute:
isDirectMessage || opts.eventScope
? {
sessionKey: updateLastRouteSessionKey,
channel: "slack",
to: replyRouteTarget,
accountId: route.accountId,
threadId: effectiveMessageThreadId,
mainDmOwnerPin:
isDirectMessage &&
updateLastRouteSessionKey === route.mainSessionKey &&
pinnedMainDmOwner &&
message.user
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: normalizeLowercaseStringOrEmpty(message.user),
onSkip: ({
ownerRecipient,
senderRecipient,
}: {
ownerRecipient: string;
senderRecipient: string;
}) => {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err: unknown) => {
ctx.logger.warn(
{
+4 -13
View File
@@ -185,9 +185,6 @@ describe("deliverReplies identity passthrough", () => {
const metadata = { event_type: "openclaw_test", event_payload: { source: "chart" } };
const listenerClient = { chat: { postMessage: vi.fn() } } as never;
const eventScope = {
apiAppId: "A1",
enterpriseId: "E1",
isEnterpriseInstall: true as const,
teamId: "T1",
client: listenerClient,
};
@@ -231,8 +228,7 @@ describe("deliverReplies identity passthrough", () => {
mediaUrl: "https://example.com/report.png",
threadTs: "thread-ts",
accountId: "work",
client: listenerClient,
enterpriseEventScope: eventScope,
eventScope,
textLimit: 4000,
mediaMaxBytes: 1024,
identity,
@@ -243,8 +239,7 @@ describe("deliverReplies identity passthrough", () => {
token: "xoxb-test",
threadTs: "thread-ts",
accountId: "work",
client: listenerClient,
enterpriseEventScope: eventScope,
eventScope,
textLimit: 4000,
mediaMaxBytes: 1024,
blocks: [
@@ -284,13 +279,10 @@ describe("deliverReplies identity passthrough", () => {
expect(options).not.toHaveProperty("identity");
});
it("forwards the validated Enterprise event scope and exact listener client", async () => {
it("forwards the validated Enterprise event scope", async () => {
sendMock.mockResolvedValue({ messageId: "123.456", channelId: "C123" });
const listenerClient = { chat: { postMessage: vi.fn() } } as never;
const eventScope = {
apiAppId: "A1",
enterpriseId: "E1",
isEnterpriseInstall: true as const,
teamId: "T1",
client: listenerClient,
};
@@ -304,8 +296,7 @@ describe("deliverReplies identity passthrough", () => {
);
const options = requireSendCall()[2];
expect(options.client).toBe(listenerClient);
expect(options.enterpriseEventScope).toBe(eventScope);
expect(options.eventScope).toBe(eventScope);
expect(options.textLimit).toBe(4000);
expect(options.mediaMaxBytes).toBe(1024);
});
+1 -2
View File
@@ -173,8 +173,7 @@ export async function deliverReplies(params: {
...(input.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}),
...(params.eventScope
? {
client: params.eventScope.client,
enterpriseEventScope: params.eventScope,
eventScope: params.eventScope,
textLimit: params.textLimit,
...(params.mediaMaxBytes !== undefined ? { mediaMaxBytes: params.mediaMaxBytes } : {}),
}
+33 -27
View File
@@ -15,6 +15,7 @@ const loadOutboundMediaFromUrl = vi.hoisted(() =>
})),
);
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
const getSlackWriteClientMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/fetch-runtime", () => ({
withTrustedEnvProxyGuardedFetchMode: (value: unknown) => value,
@@ -24,6 +25,10 @@ vi.mock("./runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
return { ...actual, loadOutboundMediaFromUrl };
});
vi.mock("./client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./client.js")>();
return { ...actual, getSlackWriteClient: getSlackWriteClientMock };
});
const { sendMessageSlack } = await import("./send.js");
@@ -63,15 +68,8 @@ function createEnterpriseClient(): EnterpriseTestClient {
} as unknown as EnterpriseTestClient;
}
function enterpriseEventScope(
client: WebClient,
teamId = "T1",
uploadCompletionClient: WebClient = client,
) {
function eventScope(client: WebClient, teamId = "T1", uploadCompletionClient: WebClient = client) {
return {
apiAppId: "A1",
enterpriseId: "E1",
isEnterpriseInstall: true as const,
teamId,
client,
uploadCompletionClient,
@@ -85,8 +83,7 @@ function enterpriseOptions(
) {
return {
cfg: ENTERPRISE_CFG,
client,
enterpriseEventScope: enterpriseEventScope(client, teamId, uploadCompletionClient),
eventScope: eventScope(client, teamId, uploadCompletionClient),
};
}
@@ -111,6 +108,7 @@ describe("sendMessageSlack Enterprise listener scope", () => {
clearSlackThreadParticipationCache();
loadOutboundMediaFromUrl.mockClear();
fetchWithSsrFGuard.mockReset();
getSlackWriteClientMock.mockReset();
});
afterEach(() => {
@@ -132,16 +130,28 @@ describe("sendMessageSlack Enterprise listener scope", () => {
expect(client.chat.postMessage).not.toHaveBeenCalled();
});
it("requires the exact validated listener client and an Enterprise account", async () => {
const client = createEnterpriseClient();
const otherClient = createEnterpriseClient();
it("creates a workspace-scoped client for a qualified detached send", async () => {
const scopedClient = createEnterpriseClient();
const injectedClient = createEnterpriseClient();
getSlackWriteClientMock.mockReturnValue(scopedClient);
await expect(
sendMessageSlack("channel:C123", "hello", {
...enterpriseOptions(client),
client: otherClient,
}),
).rejects.toThrow("invalid_enterprise_slack_listener_scope");
await sendMessageSlack("team:T123:channel:C08GQH53EJM", "hello", {
cfg: ENTERPRISE_CFG,
token: "xoxb-enterprise",
client: injectedClient,
});
expect(getSlackWriteClientMock).toHaveBeenCalledWith("xoxb-enterprise", {
teamId: "T123",
});
expect(scopedClient.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: "C08GQH53EJM", text: "hello" }),
);
expect(injectedClient.chat.postMessage).not.toHaveBeenCalled();
});
it("requires an Enterprise account for an event scope", async () => {
const client = createEnterpriseClient();
await expect(
sendMessageSlack("channel:C123", "hello", {
...enterpriseOptions(client),
@@ -149,7 +159,6 @@ describe("sendMessageSlack Enterprise listener scope", () => {
}),
).rejects.toThrow("unexpected_enterprise_slack_listener_scope");
expect(client.chat.postMessage).not.toHaveBeenCalled();
expect(otherClient.chat.postMessage).not.toHaveBeenCalled();
});
it("uses the exact listener client without a token or team_id method payload", async () => {
@@ -217,11 +226,10 @@ describe("sendMessageSlack Enterprise listener scope", () => {
const replacementClient = createEnterpriseClient();
const firstDeferred = deferredPostMessage("1.000");
firstClient.chat.postMessage.mockReturnValueOnce(firstDeferred.promise);
const secondScope = enterpriseEventScope(secondClient, "T1");
const secondScope = eventScope(secondClient, "T1");
const secondOptions = {
cfg: ENTERPRISE_CFG,
client: secondClient as WebClient,
enterpriseEventScope: secondScope,
eventScope: secondScope,
};
const first = sendMessageSlack("C123", "first", enterpriseOptions(firstClient, "T1"));
@@ -230,7 +238,6 @@ describe("sendMessageSlack Enterprise listener scope", () => {
await Promise.resolve();
expect(secondClient.chat.postMessage).not.toHaveBeenCalled();
secondOptions.client = replacementClient;
secondScope.client = replacementClient;
firstDeferred.release();
await Promise.all([first, second]);
@@ -286,14 +293,13 @@ describe("sendMessageSlack Enterprise listener scope", () => {
it("rejects Enterprise media before upload without the one-shot completion client", async () => {
const client = createEnterpriseClient();
const scope = enterpriseEventScope(client);
const scope = eventScope(client);
delete (scope as { uploadCompletionClient?: WebClient }).uploadCompletionClient;
await expect(
sendMessageSlack("C123", "caption", {
cfg: ENTERPRISE_CFG,
client,
enterpriseEventScope: scope,
eventScope: scope,
mediaUrl: "https://example.com/image.png",
}),
).rejects.toThrow("missing_enterprise_slack_upload_completion_client");
@@ -104,10 +104,6 @@ describe("sendMessageSlack customize-scope fallback", () => {
{ target: "channel:companychat", expected: "companychat" },
{ target: "#companychat", expected: "companychat" },
{ target: "#c08gqh53ejm", expected: "c08gqh53ejm" },
{
target: "team:T123:channel:C08GQH53EJM",
expected: "team:T123:channel:C08GQH53EJM",
},
])("resolves API target $target as $expected", async ({ target, expected }) => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage).mockResolvedValueOnce({ ts: "171234.567" });
+62 -20
View File
@@ -82,6 +82,15 @@ function createUnknownSendContext(
};
}
function reconcileWithClient(
ctx: ChannelMessageUnknownSendContext,
client: SlackReconcileTestClient,
) {
slackClientMocks.createSlackReadClient.mockReturnValue(client);
slackClientMocks.getSlackWriteClient.mockReturnValue(client);
return reconcileSlackUnknownSend(ctx);
}
async function postWithDeliveryMetadata(params: {
client: SlackReconcileTestClient;
queueId?: string;
@@ -109,6 +118,37 @@ describe("reconcileSlackUnknownSend", () => {
slackClientMocks.getSlackWriteClient.mockReset();
});
it("uses workspace-scoped clients for an Enterprise reconciliation", async () => {
const readClient = createSlackReconcileTestClient();
const writeClient = createSlackReconcileTestClient();
slackClientMocks.createSlackReadClient.mockReturnValue(readClient);
slackClientMocks.getSlackWriteClient.mockReturnValue(writeClient);
await reconcileSlackUnknownSend(
createUnknownSendContext({
cfg: {
channels: {
slack: {
botToken: "xoxb-org",
enterpriseOrgInstall: true,
},
},
} as OpenClawConfig,
to: "team:T123:channel:C123",
}),
);
expect(slackClientMocks.createSlackReadClient).toHaveBeenCalledWith("xoxb-org", {
teamId: "T123",
});
expect(slackClientMocks.getSlackWriteClient).toHaveBeenCalledWith("xoxb-org", {
teamId: "T123",
});
expect(readClient.conversations.history).toHaveBeenCalledWith(
expect.objectContaining({ channel: "C123" }),
);
});
it("attaches an opaque durable id and reconciles the exact posted message", async () => {
const client = createSlackReconcileTestClient();
const metadata = await postWithDeliveryMetadata({ client });
@@ -120,7 +160,7 @@ describe("reconcileSlackUnknownSend", () => {
],
});
const result = await reconcileSlackUnknownSend(createUnknownSendContext(), { client });
const result = await reconcileWithClient(createUnknownSendContext(), client);
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C123",
@@ -156,7 +196,7 @@ describe("reconcileSlackUnknownSend", () => {
messages: [{ ts: "1782584647.000002", metadata }],
});
const reconciled = await reconcileSlackUnknownSend(createUnknownSendContext(), { client });
const reconciled = await reconcileWithClient(createUnknownSendContext(), client);
expect(reconciled.status).toBe("sent");
if (reconciled.status === "sent") {
expect(reconciled.receipt.platformMessageIds).toEqual(["1782584647.000002"]);
@@ -261,7 +301,7 @@ describe("reconcileSlackUnknownSend", () => {
],
});
const reconciled = await reconcileSlackUnknownSend(createUnknownSendContext(), { client });
const reconciled = await reconcileWithClient(createUnknownSendContext(), client);
expect(reconciled.status).toBe("sent");
if (reconciled.status === "sent") {
expect(reconciled.receipt.platformMessageIds).toEqual([
@@ -375,8 +415,12 @@ describe("reconcileSlackUnknownSend", () => {
await expect(
reconcileSlackUnknownSend(createUnknownSendContext({ cfg: tokenCfg })),
).resolves.toEqual(expect.objectContaining({ status: "sent" }));
expect(slackClientMocks.createSlackReadClient).toHaveBeenCalledWith("xoxp-read");
expect(slackClientMocks.getSlackWriteClient).toHaveBeenCalledWith("xoxb-write");
expect(slackClientMocks.createSlackReadClient).toHaveBeenCalledWith("xoxp-read", {
teamId: undefined,
});
expect(slackClientMocks.getSlackWriteClient).toHaveBeenCalledWith("xoxb-write", {
teamId: undefined,
});
expect(readClient.conversations.history).toHaveBeenCalledOnce();
expect(writeClient.conversations.history).not.toHaveBeenCalled();
});
@@ -442,15 +486,13 @@ describe("reconcileSlackUnknownSend", () => {
messages: [{ ts: "1782584647.000002", text: "final answer" }],
});
await expect(
reconcileSlackUnknownSend(createUnknownSendContext(), { client }),
).resolves.toEqual({
await expect(reconcileWithClient(createUnknownSendContext(), client)).resolves.toEqual({
status: "unresolved",
error: "Slack history contains no exact durable delivery marker",
retryable: true,
});
await expect(
reconcileSlackUnknownSend(createUnknownSendContext({ retryCount: 2 }), { client }),
reconcileWithClient(createUnknownSendContext({ retryCount: 2 }), client),
).resolves.toEqual({
status: "unresolved",
error: "Slack history contains no exact durable delivery marker",
@@ -474,13 +516,13 @@ describe("reconcileSlackUnknownSend", () => {
],
});
const result = await reconcileSlackUnknownSend(
const result = await reconcileWithClient(
createUnknownSendContext({
threadId: "1782584644.111111",
payloads: [{ text: "final answer", replyToId: "1782584644.222222" }],
effectiveReplyToId: "1782584644.377229",
}),
{ client },
client,
);
expect(client.conversations.replies).toHaveBeenCalledWith(
@@ -523,7 +565,7 @@ describe("reconcileSlackUnknownSend", () => {
messages: [{ ts: "1782584647.000002", metadata }],
});
const result = await reconcileSlackUnknownSend(createUnknownSendContext(overrides), { client });
const result = await reconcileWithClient(createUnknownSendContext(overrides), client);
expect(result.status).toBe("sent");
expect(client.conversations.history).toHaveBeenCalledOnce();
@@ -543,9 +585,9 @@ describe("reconcileSlackUnknownSend", () => {
messages: [{ ts: "1782584647.000002", metadata }],
});
await expect(
reconcileSlackUnknownSend(createUnknownSendContext(), { client }),
).resolves.toEqual(expect.objectContaining({ status: "sent", messageId: "1782584647.000002" }));
await expect(reconcileWithClient(createUnknownSendContext(), client)).resolves.toEqual(
expect.objectContaining({ status: "sent", messageId: "1782584647.000002" }),
);
expect(client.conversations.history).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: "cursor-2" }),
@@ -594,9 +636,9 @@ describe("reconcileSlackUnknownSend", () => {
})),
});
const result = await reconcileSlackUnknownSend(
const result = await reconcileWithClient(
createUnknownSendContext({ cfg: chunkedCfg, payloads: [{ text: "final answer" }] }),
{ client },
client,
);
expect(result.status).toBe("sent");
if (result.status === "sent") {
@@ -628,9 +670,9 @@ describe("reconcileSlackUnknownSend", () => {
],
});
await expect(
reconcileSlackUnknownSend(
reconcileWithClient(
createUnknownSendContext({ cfg: chunkedCfg, payloads: [{ text: "final answer" }] }),
{ client },
client,
),
).resolves.toEqual({
status: "unresolved",
@@ -660,7 +702,7 @@ describe("reconcileSlackUnknownSend", () => {
],
});
const reconciled = await reconcileSlackUnknownSend(createUnknownSendContext(), { client });
const reconciled = await reconcileWithClient(createUnknownSendContext(), client);
expect(reconciled.status).toBe("sent");
if (reconciled.status === "sent") {
+140 -119
View File
@@ -41,14 +41,17 @@ import {
} from "./client-delivery.js";
import { createSlackReadClient, createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
import { assertSlackDirectSendAllowed } from "./direct-send-admission.js";
import { formatSlackError } from "./errors.js";
import { chunkSlackMrkdwnText, markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_EDIT_TEXT_MAX_BYTES, SLACK_TEXT_LIMIT } from "./limits.js";
import type { SlackEventScope } from "./monitor/event-scope.js";
import {
buildSlackNativeDataAccessibilityText,
hasSlackNativeDataBlock,
isSlackInvalidBlocksError,
} from "./native-data-blocks.js";
import { buildSlackNativeDataDeliveryPlan } from "./native-data-fallback.js";
import type { SlackUnfurlOptions } from "./post-message-payload.js";
import {
resolveSlackQuestionActionIds,
SLACK_QUESTION_FINALIZATION_BLOCKS,
@@ -75,10 +78,12 @@ type SlackRecipient =
| {
kind: "user";
id: string;
teamId?: string;
}
| {
kind: "channel";
id: string;
teamId?: string;
};
export type SlackSendIdentity = {
@@ -87,19 +92,18 @@ export type SlackSendIdentity = {
iconEmoji?: string;
};
type SlackEnterpriseEventScope = Readonly<{
apiAppId: string;
enterpriseId: string;
teamId: string;
isEnterpriseInstall: true;
type SlackResolvedDelivery = Readonly<{
client: WebClient;
uploadCompletionClient?: WebClient;
}>;
type SlackEnterpriseDelivery = Readonly<{
client: WebClient;
teamId: string;
uploadCompletionClient?: WebClient;
credential: string;
identity?: SlackSendIdentity;
recipient: SlackRecipient;
requireMessageTimestamp: boolean;
teamId?: string;
unfurl: SlackUnfurlOptions;
upload?: Readonly<{
completionClient: WebClient;
auditContext: string;
}>;
}>;
const slackDefaultSendIdentities = new Map<string, SlackSendIdentity>();
@@ -118,8 +122,8 @@ type SlackSendOpts = {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
client?: WebClient;
/** Monitor-private proof that `client` belongs to the validated Enterprise event turn. */
enterpriseEventScope?: SlackEnterpriseEventScope;
/** Monitor-private listener context validated from the active event. */
eventScope?: SlackEventScope;
/** Monitor-private delivery limits already resolved for the active listener. */
textLimit?: number;
/** Slack-private marker for text that is already safe mrkdwn and must not be parsed again. */
@@ -365,6 +369,7 @@ function parseRecipient(raw: string): SlackRecipient {
return {
kind: target.kind,
id: canonicalizeSlackApiTargetId(target.kind, target.id, raw),
teamId: target.teamId,
};
}
@@ -376,31 +381,96 @@ function parseEnterpriseEventRecipient(raw: string): SlackRecipient {
return { kind: "channel", id: canonicalizeSlackApiTargetId("channel", match[1]) };
}
function resolveEnterpriseEventScope(params: {
function resolveSlackSendEventScope(params: {
account: ReturnType<typeof resolveSlackAccount>;
opts: SlackSendOpts;
}): SlackEnterpriseEventScope | undefined {
const scope = params.opts.enterpriseEventScope;
}): SlackEventScope | undefined {
const scope = params.opts.eventScope;
if (!scope) {
assertSlackDirectSendAllowed(params.account);
return undefined;
}
if (params.account.config.enterpriseOrgInstall !== true) {
throw new Error("unexpected_enterprise_slack_listener_scope");
}
if (
!scope.isEnterpriseInstall ||
!normalizeOptionalString(scope.apiAppId) ||
!normalizeOptionalString(scope.enterpriseId) ||
!/^T[A-Z0-9]+$/i.test(scope.teamId) ||
!scope.client ||
params.opts.client !== scope.client
) {
if (!/^T[A-Z0-9]+$/i.test(scope.teamId) || !scope.client) {
throw new Error("invalid_enterprise_slack_listener_scope");
}
return scope;
}
function resolveSlackDelivery(params: {
account: ReturnType<typeof resolveSlackAccount>;
eventScope?: SlackEventScope;
opts: Readonly<SlackSendOpts>;
recipient: SlackRecipient;
}): SlackResolvedDelivery {
if (params.eventScope) {
if (params.opts.mediaUrl && !params.eventScope.uploadCompletionClient) {
throw new Error("missing_enterprise_slack_upload_completion_client");
}
return Object.freeze({
client: params.eventScope.client,
credential: SLACK_ENTERPRISE_LISTENER_QUEUE_CREDENTIAL,
identity: normalizeSlackSendIdentity(params.opts.identity),
recipient: params.recipient,
requireMessageTimestamp: true,
teamId: params.eventScope.teamId,
unfurl: { unfurlMedia: params.account.config.unfurlMedia },
...(params.eventScope.uploadCompletionClient
? {
upload: Object.freeze({
completionClient: params.eventScope.uploadCompletionClient,
auditContext: "slack-enterprise-immediate-upload",
}),
}
: {}),
});
}
const credential = resolveToken({
explicit: params.opts.token,
accountId: params.account.accountId,
fallbackToken: resolveSlackOperationToken(params.account, "write"),
fallbackSource:
params.account.identity === "user"
? params.account.userTokenSource
: params.account.botTokenSource,
});
return Object.freeze({
client: params.recipient.teamId
? getSlackWriteClient(credential, { teamId: params.recipient.teamId })
: (params.opts.client ?? getSlackWriteClient(credential)),
credential,
identity: resolveSlackSendIdentity({
accountId: params.account.accountId,
explicit: params.opts.identity,
}),
recipient: params.recipient,
requireMessageTimestamp: false,
teamId: params.recipient.teamId,
unfurl: params.recipient.teamId
? { unfurlMedia: params.account.config.unfurlMedia }
: {
unfurlLinks: params.account.config.unfurlLinks,
unfurlMedia: params.account.config.unfurlMedia,
},
});
}
function assertSlackPostMessageResponse(
response: { ok?: boolean; ts?: string; error?: unknown },
required: boolean,
): void {
if (!required || (response.ok && response.ts)) {
return;
}
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${formatSlackError(response.error, "unknown error")}`,
);
}
function resolveSlackTextChunkLimit(params: {
cfg: OpenClawConfig;
accountId?: string;
@@ -870,7 +940,6 @@ async function scanSlackConversationForDelivery(params: {
export async function reconcileSlackUnknownSend(
ctx: ChannelMessageUnknownSendContext,
opts?: { client?: SlackConversationLookupClient },
): Promise<ChannelMessageUnknownSendReconciliationResult> {
const cfg = requireRuntimeConfig(ctx.cfg, "Slack delivery reconciliation");
const account = resolveSlackAccount({
@@ -885,6 +954,16 @@ export async function reconcileSlackUnknownSend(
retryable: false,
};
}
const recipient = parseRecipient(ctx.to);
try {
assertSlackDirectSendAllowed(account, recipient.teamId);
} catch (error) {
return {
status: "unresolved",
error: error instanceof Error ? error.message : String(error),
retryable: false,
};
}
const readToken = resolveSlackOperationToken(account, "read");
if (!readToken) {
return {
@@ -893,7 +972,6 @@ export async function reconcileSlackUnknownSend(
retryable: false,
};
}
const recipient = parseRecipient(ctx.to);
const userRecipient = isSlackUserRecipient(recipient);
const writeToken = resolveSlackOperationToken(account, "write");
if (userRecipient && !writeToken) {
@@ -903,8 +981,10 @@ export async function reconcileSlackUnknownSend(
retryable: false,
};
}
const readClient = opts?.client ?? createSlackReadClient(readToken);
const writeClient = opts?.client ?? (writeToken ? getSlackWriteClient(writeToken) : undefined);
const readClient = createSlackReadClient(readToken, { teamId: recipient.teamId });
const writeClient = writeToken
? getSlackWriteClient(writeToken, { teamId: recipient.teamId })
: undefined;
const payloadReplyToId = ctx.payloads[0]?.replyToId;
const effectiveReplyToId = Object.hasOwn(ctx, "effectiveReplyToId")
? normalizeOptionalString(ctx.effectiveReplyToId)
@@ -935,9 +1015,12 @@ export async function reconcileSlackUnknownSend(
accountId: account.accountId,
token: channelToken,
});
const lookupClients = opts?.client
? [opts.client]
: [readClient, ...(writeClient && writeToken !== readToken ? [writeClient] : [])];
const lookupClients = [
readClient,
...(writeClient && writeClient !== readClient && writeToken !== readToken
? [writeClient]
: []),
];
let lookupError: unknown;
let bestUnresolvedScan: SlackConversationDeliveryScan | undefined;
for (const lookupClient of lookupClients) {
@@ -993,16 +1076,11 @@ export async function sendMessageSlack(
cfg,
accountId: opts.accountId,
});
const enterpriseEventScope = resolveEnterpriseEventScope({ account, opts });
const enterpriseDelivery = enterpriseEventScope
? Object.freeze({
client: enterpriseEventScope.client,
teamId: enterpriseEventScope.teamId,
...(enterpriseEventScope.uploadCompletionClient
? { uploadCompletionClient: enterpriseEventScope.uploadCompletionClient }
: {}),
})
: undefined;
const eventScope = resolveSlackSendEventScope({ account, opts });
const recipient = eventScope ? parseEnterpriseEventRecipient(to) : parseRecipient(to);
if (!eventScope) {
assertSlackDirectSendAllowed(account, recipient.teamId);
}
if (isSilentReplyText(normalizedMessage) && !opts.mediaUrl && !opts.blocks) {
logVerbose("slack send: suppressed NO_REPLY token before API call");
return {
@@ -1015,47 +1093,30 @@ export async function sendMessageSlack(
if (!normalizedMessage && !opts.mediaUrl && !blocks) {
throw new Error("Slack send requires text, blocks, or media");
}
const token = enterpriseDelivery
? SLACK_ENTERPRISE_LISTENER_QUEUE_CREDENTIAL
: resolveToken({
explicit: opts.token,
accountId: account.accountId,
fallbackToken: resolveSlackOperationToken(account, "write"),
fallbackSource:
account.identity === "user" ? account.userTokenSource : account.botTokenSource,
});
const recipient = enterpriseDelivery ? parseEnterpriseEventRecipient(to) : parseRecipient(to);
const queuedOpts = Object.freeze({ ...opts });
const delivery = resolveSlackDelivery({ account, eventScope, opts: queuedOpts, recipient });
const queueKey = createSlackSendQueueKey({
accountId: account.accountId,
token,
recipient,
token: delivery.credential,
recipient: delivery.recipient,
threadTs: opts.threadTs,
...(enterpriseDelivery ? { teamId: enterpriseDelivery.teamId } : {}),
teamId: delivery.teamId,
});
const queuedOpts = enterpriseDelivery
? Object.freeze({ ...opts, client: enterpriseDelivery.client })
: opts;
const result = await runQueuedSlackSend(queueKey, () =>
sendMessageSlackQueued({
trimmedMessage,
opts: queuedOpts,
cfg,
account,
token,
recipient,
blocks,
...(enterpriseDelivery ? { enterpriseDelivery } : {}),
delivery,
}),
);
const threadTs = result.threadTs ?? normalizeSlackThreadTsCandidate(queuedOpts.threadTs);
if (threadTs && result.channelId && account.accountId) {
if (enterpriseDelivery) {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs, {
teamId: enterpriseDelivery.teamId,
});
} else {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs);
}
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs, {
teamId: delivery.teamId,
});
}
return result;
}
@@ -1065,10 +1126,8 @@ async function sendMessageSlackQueued(params: {
opts: SlackSendOpts;
cfg: OpenClawConfig;
account: ReturnType<typeof resolveSlackAccount>;
token: string;
recipient: SlackRecipient;
blocks?: (Block | KnownBlock)[];
enterpriseDelivery?: SlackEnterpriseDelivery;
delivery: SlackResolvedDelivery;
}): Promise<SlackSendResult> {
try {
return await sendMessageSlackQueuedInner(params);
@@ -1082,29 +1141,14 @@ async function sendMessageSlackQueuedInner(params: {
opts: SlackSendOpts;
cfg: OpenClawConfig;
account: ReturnType<typeof resolveSlackAccount>;
token: string;
recipient: SlackRecipient;
blocks?: (Block | KnownBlock)[];
enterpriseDelivery?: SlackEnterpriseDelivery;
delivery: SlackResolvedDelivery;
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage, enterpriseDelivery } =
params;
const client = enterpriseDelivery?.client ?? opts.client ?? getSlackWriteClient(token);
const identity = enterpriseDelivery
? normalizeSlackSendIdentity(opts.identity)
: resolveSlackSendIdentity({
accountId: account.accountId,
explicit: opts.identity,
});
const { opts, cfg, account, blocks, trimmedMessage, delivery } = params;
const { client, identity, recipient, unfurl } = delivery;
if (opts.replyBroadcast && opts.mediaUrl) {
throw new Error("Slack replyBroadcast is only supported for text or block thread replies.");
}
const unfurl = enterpriseDelivery
? { unfurlMedia: account.config.unfurlMedia }
: {
unfurlLinks: account.config.unfurlLinks,
unfurlMedia: account.config.unfurlMedia,
};
// Durable signatures bind the concrete provider channel, so user-targeted
// sends must resolve U... to the resulting D... conversation first.
const directUserPostChannelId = opts.deliveryQueueId
@@ -1118,7 +1162,7 @@ async function sendMessageSlackQueuedInner(params: {
? { channelId: directUserPostChannelId }
: await resolveChannelId(client, recipient, {
accountId: account.accountId,
token,
token: delivery.credential,
});
const reportDelivery = async (
result: SlackSendResult,
@@ -1239,13 +1283,7 @@ async function sendMessageSlackQueuedInner(params: {
...(usesOrderedBlockAccessibility ? { mrkdwn: false } : {}),
unfurl,
});
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
assertSlackPostMessageResponse(response, delivery.requireMessageTimestamp);
const messageId = response.ts ?? "unknown";
deliveredChannelId = resolvePostedMessageChannelId(response, channelId);
const deliveredThreadTs =
@@ -1299,13 +1337,7 @@ async function sendMessageSlackQueuedInner(params: {
unfurl,
});
const response = posted.response;
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
assertSlackPostMessageResponse(response, delivery.requireMessageTimestamp);
sendIdentity = posted.identity;
lastMessageId = response.ts ?? lastMessageId;
deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId);
@@ -1376,15 +1408,10 @@ async function sendMessageSlackQueuedInner(params: {
let chunksToPost: string[];
if (opts.mediaUrl) {
if (enterpriseDelivery && !enterpriseDelivery.uploadCompletionClient) {
throw new Error("missing_enterprise_slack_upload_completion_client");
}
const [firstChunk, ...rest] = resolvedChunks;
lastMessageId = await uploadSlackFile({
client,
...(enterpriseDelivery?.uploadCompletionClient
? { completionClient: enterpriseDelivery.uploadCompletionClient }
: {}),
...(delivery.upload ? { completionClient: delivery.upload.completionClient } : {}),
channelId,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
@@ -1396,7 +1423,7 @@ async function sendMessageSlackQueuedInner(params: {
threadTs: opts.threadTs,
maxBytes: mediaMaxBytes,
onPlatformSendDispatch: dispatchOnce,
...(enterpriseDelivery ? { auditContext: "slack-enterprise-immediate-upload" } : {}),
...(delivery.upload ? { auditContext: delivery.upload.auditContext } : {}),
});
sentMessageIds.push(lastMessageId);
await reportDelivery({
@@ -1445,13 +1472,7 @@ async function sendMessageSlackQueuedInner(params: {
unfurl,
});
const response = posted.response;
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
assertSlackPostMessageResponse(response, delivery.requireMessageTimestamp);
sendIdentity = posted.identity;
lastMessageId = response.ts ?? lastMessageId;
deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId);
+62 -1
View File
@@ -11,7 +11,9 @@ import {
export type SlackTargetKind = MessagingTargetKind;
export type SlackTarget = MessagingTarget;
export type SlackTarget = MessagingTarget & {
teamId?: string;
};
export type SlackTargetParseOptions = MessagingTargetParseOptions;
@@ -19,6 +21,58 @@ export type SlackTargetParseOptions = MessagingTargetParseOptions;
// Doctor reports that ambiguity; runtime repairs only the digit-leading form.
const SLACK_CHANNEL_API_ID_RE = /^[CDG][0-9][A-Z0-9]{7,}$/i;
const SLACK_USER_API_ID_RE = /^[UW][A-Z0-9]{8,}$/i;
const SLACK_QUALIFIED_TARGET_RE = /^team:([^:]+):(user|channel):([^:]+)$/i;
function decodeSlackTargetPart(raw: string): string | undefined {
try {
return decodeURIComponent(raw).trim() || undefined;
} catch {
return undefined;
}
}
function parseQualifiedSlackTarget(raw: string): SlackTarget | undefined {
const match = SLACK_QUALIFIED_TARGET_RE.exec(raw);
if (!match) {
if (/^team:/i.test(raw)) {
throw new Error(
"Slack workspace targets require team:<team-id>:channel:<channel-id> or team:<team-id>:user:<user-id>",
);
}
return undefined;
}
const teamId = decodeSlackTargetPart(match[1] ?? "");
const kind = match[2]?.toLowerCase() as SlackTargetKind | undefined;
const id = decodeSlackTargetPart(match[3] ?? "");
const idPattern = kind === "user" ? /^[UW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i;
if (!teamId || !/^T[A-Z0-9]+$/i.test(teamId) || !kind || !id || !idPattern.test(id)) {
throw new Error("Invalid Slack workspace-qualified target");
}
return {
kind,
id,
teamId,
raw,
normalized: `team:${teamId.toLowerCase()}:${kind}:${id.toLowerCase()}`,
};
}
export function formatSlackTarget(params: {
teamId?: string;
kind: SlackTargetKind;
id: string;
}): string {
const teamId = params.teamId?.trim();
const id = params.id.trim();
if (!teamId) {
return id;
}
const idPattern = params.kind === "user" ? /^[UW][A-Z0-9]+$/i : /^[CDG][A-Z0-9]+$/i;
if (!/^T[A-Z0-9]+$/i.test(teamId) || !idPattern.test(id)) {
throw new Error("Invalid Slack workspace-qualified target");
}
return `team:${encodeURIComponent(teamId)}:${params.kind}:${encodeURIComponent(id)}`;
}
function isUnambiguousSlackUserId(rawId: string): boolean {
const id = rawId.trim();
@@ -47,6 +101,10 @@ export function parseSlackTarget(
if (!trimmed) {
return undefined;
}
const qualifiedTarget = parseQualifiedSlackTarget(trimmed);
if (qualifiedTarget) {
return qualifiedTarget;
}
const userTarget = parseMentionPrefixOrAtUserTarget({
raw: trimmed,
mentionPattern: /^<@([A-Z0-9]+)>$/i,
@@ -109,6 +167,9 @@ export function looksLikeSlackTargetId(raw: string): boolean {
if (/^slack:/i.test(trimmed)) {
return true;
}
if (/^team:/i.test(trimmed)) {
return true;
}
if (/^[@#]/.test(trimmed)) {
return true;
}
+37 -1
View File
@@ -1,6 +1,10 @@
// Slack tests cover targets plugin behavior.
import { describe, expect, it } from "vitest";
import { canonicalizeSlackApiTargetId, slackTargetsMatch } from "./target-parsing.js";
import {
canonicalizeSlackApiTargetId,
formatSlackTarget,
slackTargetsMatch,
} from "./target-parsing.js";
import {
normalizeSlackMessagingTarget,
parseSlackTarget,
@@ -46,6 +50,33 @@ describe("parseSlackTarget", () => {
}
});
it("parses workspace-qualified Slack targets", () => {
expect(parseSlackTarget("team:T123:channel:C456")).toEqual({
kind: "channel",
id: "C456",
teamId: "T123",
raw: "team:T123:channel:C456",
normalized: "team:t123:channel:c456",
});
expect(parseSlackTarget("team:T789:user:U012")).toEqual({
kind: "user",
id: "U012",
teamId: "T789",
raw: "team:T789:user:U012",
normalized: "team:t789:user:u012",
});
});
it("formats bare and structurally valid workspace-qualified targets", () => {
expect(formatSlackTarget({ teamId: "T123", kind: "channel", id: "C456" })).toBe(
"team:T123:channel:C456",
);
expect(formatSlackTarget({ kind: "channel", id: "C456" })).toBe("C456");
expect(() => formatSlackTarget({ teamId: "E123", kind: "channel", id: "C456" })).toThrow(
"Invalid Slack workspace-qualified target",
);
});
it("rejects invalid @ and # targets", () => {
const cases = [
{ input: "@bob-1", expectedMessage: /Slack DMs require a user id/ },
@@ -115,6 +146,11 @@ describe("slackTargetsMatch", () => {
it("does not match different target kinds", () => {
expect(slackTargetsMatch("user:U123", "channel:U123")).toBe(false);
});
it("keeps identically named targets in different workspaces distinct", () => {
expect(slackTargetsMatch("team:T1:channel:C123", "team:T2:channel:C123")).toBe(false);
expect(slackTargetsMatch("team:T1:channel:C123", "channel:C123")).toBe(false);
});
});
describe("slackContextTargetsMatch", () => {
@@ -312,4 +312,34 @@ describe("buildSlackThreadingToolContext", () => {
expect(result.currentChannelId).toBe("user:U8SUVSVGS");
expect(result.currentMessagingTarget).toBe("user:U8SUVSVGS");
});
it("keeps an Enterprise channel target workspace-qualified", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: {
ChatType: "channel",
To: "team:T123:channel:C1234ABC",
NativeChannelId: "C1234ABC",
},
});
expect(result.currentChannelId).toBe("team:T123:channel:C1234ABC");
expect(result.currentMessagingTarget).toBe("team:T123:channel:C1234ABC");
});
it("uses the physical Enterprise DM channel without losing its workspace", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: {
ChatType: "direct",
To: "team:T123:user:U8SUVSVGS",
NativeChannelId: "D8SRXRDNF",
},
});
expect(result.currentChannelId).toBe("team:T123:channel:D8SRXRDNF");
expect(result.currentMessagingTarget).toBe("team:T123:user:U8SUVSVGS");
});
});
+17 -6
View File
@@ -6,6 +6,7 @@ import type {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
import { formatSlackTarget, parseSlackTarget } from "./target-parsing.js";
import { normalizeSlackThreadTsCandidate } from "./thread-ts.js";
export function buildSlackThreadingToolContext(params: {
@@ -32,13 +33,23 @@ export function buildSlackThreadingToolContext(params: {
transportThreadTs != null ||
(replyToThreadTs != null && currentMessageTs != null && replyToThreadTs !== currentMessageTs);
const effectiveReplyToMode = hasExplicitThreadTarget ? "all" : configuredReplyToMode;
// For channel messages, To is "channel:C…" — extract the bare ID.
// For DMs, prefer NativeChannelId for channel-scoped actions, but keep the
// user target as a valid implicit send destination when no D… id is known.
const currentMessagingTarget = normalizeOptionalString(params.context.To);
const currentChannelId = currentMessagingTarget?.startsWith("channel:")
? currentMessagingTarget.slice("channel:".length)
: (normalizeOptionalString(params.context.NativeChannelId) ?? currentMessagingTarget);
const parsedMessagingTarget = currentMessagingTarget
? parseSlackTarget(currentMessagingTarget)
: undefined;
const nativeChannelId = normalizeOptionalString(params.context.NativeChannelId);
const currentChannelId =
parsedMessagingTarget?.teamId && nativeChannelId
? formatSlackTarget({
teamId: parsedMessagingTarget.teamId,
kind: "channel",
id: nativeChannelId,
})
: parsedMessagingTarget?.teamId
? currentMessagingTarget
: parsedMessagingTarget?.kind === "channel"
? parsedMessagingTarget.id
: (nativeChannelId ?? currentMessagingTarget);
return {
currentChannelId,
currentMessagingTarget,