feat(slack): add unfurl controls

Co-authored-by: Hemantsudarshan <hemanthsudarshan2002@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-10 15:29:12 +01:00
parent fa2b97da4a
commit 8e700ba317
11 changed files with 290 additions and 9 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Agents: trim default system prompt guidance and send-only message tool schemas to reduce prompt tokens while preserving GPT-5 personality guidance.
- Context: add `/context map` to send a treemap image of the current session context contributors. (#79867)
- Slack: add `unfurlLinks` and `unfurlMedia` config for bot `chat.postMessage` replies, including per-account overrides, so Slack link and media previews can be suppressed without workspace-wide settings. Fixes #48435. (#80145) Thanks @esegev1 and @HemantSudarshan.
- Plugin SDK: deprecate public subpaths that existed for at least one month and have no bundled extension production imports, keep legacy barrel/test/zod subpath package exports for backwards compatibility, and track both sets in the SDK surface report.
- Plugin SDK: deprecate public subpaths currently used by only one or two bundled plugin owners, keeping them importable while steering new plugin code to focused shared SDK seams or plugin-owned APIs.
- Plugin SDK: remove the owner-specific `provider-auth-login` public subpath after moving Chutes, GitHub Copilot, and OpenAI Codex auth flows back to provider-owned modules.

View File

@@ -1,4 +1,4 @@
f8d50da8d51a648598ed9165a6994af254e73e64ad037dc26b4742198b078a8c config-baseline.json
c9e88800854b697cb3c9721d0087eb2bc7bcf6ae7239cb51d9849c49ef3d48e3 config-baseline.json
67c58457ed2b525975cdb053489f92a5f840c8cf982666393e111fd327dd132e config-baseline.core.json
a543b4d5132b4b0bcafa38e20d9ad07c78df1dc2b73633a6fdb03990cf3af918 config-baseline.channel.json
f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json

View File

@@ -1237,6 +1237,7 @@ Primary reference: [Configuration reference - Slack](/gateway/config-channels#sl
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `streaming.nativeTransport`, `streaming.preview.toolProgress`
- unfurls: `unfurlLinks`, `unfurlMedia` for `chat.postMessage` link/media preview control
- ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly`
</Accordion>

View File

@@ -452,6 +452,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
ephemeral: true,
},
typingReaction: "hourglass_flowing_sand",
unfurlLinks: false,
unfurlMedia: false,
textChunkLimit: 4000,
chunkMode: "length",
streaming: {
@@ -484,6 +486,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `configWrites: false` blocks Slack-initiated config writes.
- Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- `unfurlLinks` and `unfurlMedia` pass Slack's `chat.postMessage` link and media unfurl booleans through for bot replies. Omit them to keep Slack's default behavior; set them at `channels.slack.accounts.<accountId>` to override the top-level default for one account.
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).

View File

@@ -71,6 +71,51 @@ describe("resolveSlackAccount allowFrom precedence", () => {
expect(resolved.config.allowFrom).toEqual(["top"]);
});
it("merges top-level unfurl controls into named accounts", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
unfurlLinks: false,
unfurlMedia: true,
accounts: {
work: { botToken: "xoxb-work", appToken: "xapp-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.unfurlLinks).toBe(false);
expect(resolved.config.unfurlMedia).toBe(true);
});
it("prefers account-level unfurl controls over top-level defaults", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
unfurlLinks: false,
unfurlMedia: true,
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
unfurlLinks: true,
unfurlMedia: false,
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.unfurlLinks).toBe(true);
expect(resolved.config.unfurlMedia).toBe(false);
});
it("does not inherit default account allowFrom for named account when top-level is absent", () => {
const resolved = resolveSlackAccount({
cfg: {

View File

@@ -37,6 +37,35 @@ describe("slack config schema", () => {
}
});
it("accepts unfurl controls at root and account level", () => {
const res = SlackConfigSchema.safeParse({
unfurlLinks: false,
unfurlMedia: false,
accounts: {
ops: {
unfurlLinks: true,
unfurlMedia: false,
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.unfurlLinks).toBe(false);
expect(res.data.unfurlMedia).toBe(false);
expect(res.data.accounts?.ops?.unfurlLinks).toBe(true);
expect(res.data.accounts?.ops?.unfurlMedia).toBe(false);
}
});
it("rejects invalid unfurl control types", () => {
expectSlackConfigIssue({ unfurlLinks: "false" }, "unfurlLinks");
expectSlackConfigIssue(
{ accounts: { ops: { unfurlMedia: "false" } } },
"accounts.ops.unfurlMedia",
);
});
it('rejects dmPolicy="open" without allowFrom "*"', () => {
expectSlackConfigIssue(
{

View File

@@ -62,6 +62,11 @@ export type SlackSendIdentity = {
iconEmoji?: string;
};
type SlackUnfurlOptions = {
unfurlLinks?: boolean;
unfurlMedia?: boolean;
};
type SlackSendOpts = {
cfg: OpenClawConfig;
token?: string;
@@ -98,6 +103,13 @@ function hasCustomIdentity(identity?: SlackSendIdentity): boolean {
return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji);
}
function buildSlackUnfurlPayload(options?: SlackUnfurlOptions) {
return {
...(typeof options?.unfurlLinks === "boolean" ? { unfurl_links: options.unfurlLinks } : {}),
...(typeof options?.unfurlMedia === "boolean" ? { unfurl_media: options.unfurlMedia } : {}),
};
}
function normalizeSlackApiString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
@@ -242,11 +254,13 @@ async function postSlackMessageBestEffort(params: {
threadTs?: string;
identity?: SlackSendIdentity;
blocks?: (Block | KnownBlock)[];
unfurl?: SlackUnfurlOptions;
}) {
const basePayload = {
channel: params.channelId,
text: params.text,
thread_ts: params.threadTs,
...buildSlackUnfurlPayload(params.unfurl),
...(params.blocks?.length ? { blocks: params.blocks } : {}),
};
const postChatMessage = params.client.chat.postMessage.bind(params.client.chat);
@@ -618,6 +632,10 @@ async function sendMessageSlackQueuedInner(params: {
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
const client = opts.client ?? getSlackWriteClient(token);
const unfurl = {
unfurlLinks: account.config.unfurlLinks,
unfurlMedia: account.config.unfurlMedia,
};
const directUserPostChannelId = resolveDirectUserPostChannelId({
recipient,
hasMedia: Boolean(opts.mediaUrl),
@@ -644,6 +662,7 @@ async function sendMessageSlackQueuedInner(params: {
threadTs: opts.threadTs,
identity: opts.identity,
blocks,
unfurl,
});
const messageId = response.ts ?? "unknown";
return {
@@ -705,6 +724,7 @@ async function sendMessageSlackQueuedInner(params: {
text: chunk,
threadTs: opts.threadTs,
identity: opts.identity,
unfurl,
});
lastMessageId = response.ts ?? lastMessageId;
if (response.ts) {
@@ -719,6 +739,7 @@ async function sendMessageSlackQueuedInner(params: {
text: chunk,
threadTs: opts.threadTs,
identity: opts.identity,
unfurl,
});
lastMessageId = response.ts ?? lastMessageId;
if (response.ts) {

View File

@@ -0,0 +1,175 @@
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { sendMessageSlack } from "./send.js";
type SlackUnfurlTestClient = WebClient & {
chat: { postMessage: ReturnType<typeof vi.fn> };
conversations: { open: ReturnType<typeof vi.fn> };
};
function createSlackSendTestClient(): SlackUnfurlTestClient {
return {
conversations: {
open: vi.fn(async () => ({ channel: { id: "D123" } })),
},
chat: {
postMessage: vi.fn(async () => ({ ts: "171234.567" })),
},
} as unknown as SlackUnfurlTestClient;
}
function slackConfig(slack: NonNullable<OpenClawConfig["channels"]>["slack"]): OpenClawConfig {
return { channels: { slack } };
}
function missingCustomizeScopeError(): Error {
return Object.assign(new Error("An API error occurred: missing_scope"), {
data: {
error: "missing_scope",
needed: "chat:write.customize",
},
});
}
describe("sendMessageSlack unfurl controls", () => {
it("omits Slack unfurl flags when config is unset", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "https://example.com", {
token: "xoxb-test",
cfg: slackConfig({ botToken: "xoxb-test" }),
client,
});
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.not.objectContaining({
unfurl_links: expect.any(Boolean),
unfurl_media: expect.any(Boolean),
}),
);
});
it("passes top-level Slack unfurl flags to chat.postMessage", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "https://example.com", {
token: "xoxb-test",
cfg: slackConfig({
botToken: "xoxb-test",
unfurlLinks: false,
unfurlMedia: false,
}),
client,
});
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
unfurl_links: false,
unfurl_media: false,
}),
);
});
it("lets account-level Slack unfurl flags override top-level defaults", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "https://example.com", {
token: "xoxb-test",
accountId: "work",
cfg: slackConfig({
botToken: "xoxb-root",
unfurlLinks: false,
unfurlMedia: true,
accounts: {
work: {
unfurlLinks: true,
unfurlMedia: false,
},
},
}),
client,
});
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
unfurl_links: true,
unfurl_media: false,
}),
);
});
it("applies Slack unfurl flags to block messages", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "https://example.com", {
token: "xoxb-test",
cfg: slackConfig({
botToken: "xoxb-test",
unfurlLinks: false,
unfurlMedia: false,
}),
client,
blocks: [{ type: "divider" }],
});
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
blocks: [{ type: "divider" }],
unfurl_links: false,
unfurl_media: false,
}),
);
});
it("preserves Slack unfurl flags when custom identity falls back", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockRejectedValueOnce(missingCustomizeScopeError())
.mockResolvedValueOnce({ ts: "171234.567" });
await sendMessageSlack("channel:C123", "https://example.com", {
token: "xoxb-test",
cfg: slackConfig({
botToken: "xoxb-test",
unfurlLinks: false,
unfurlMedia: false,
}),
client,
identity: {
username: "OpenClaw",
},
});
expect(client.chat.postMessage).toHaveBeenLastCalledWith(
expect.objectContaining({
unfurl_links: false,
unfurl_media: false,
}),
);
});
it("applies Slack unfurl flags to every text chunk", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "a".repeat(8500), {
token: "xoxb-test",
cfg: slackConfig({
botToken: "xoxb-test",
unfurlLinks: false,
unfurlMedia: false,
}),
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
for (const [payload] of client.chat.postMessage.mock.calls) {
expect(payload).toEqual(
expect.objectContaining({
unfurl_links: false,
unfurl_media: false,
}),
);
}
});
});

File diff suppressed because one or more lines are too long

View File

@@ -168,6 +168,10 @@ export type SlackAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Pass through Slack chat.postMessage link unfurl control. Omitted by default. */
unfurlLinks?: boolean;
/** Pass through Slack chat.postMessage media unfurl control. Omitted by default. */
unfurlMedia?: boolean;
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
streaming?: SlackChannelStreamingConfig;
mediaMaxMb?: number;

View File

@@ -1019,6 +1019,8 @@ export const SlackAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
unfurlLinks: z.boolean().optional(),
unfurlMedia: z.boolean().optional(),
streaming: SlackStreamingConfigSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),