mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:14:44 +00:00
feat(slack): add unfurl controls
Co-authored-by: Hemantsudarshan <hemanthsudarshan2002@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
175
extensions/slack/src/send.unfurl.test.ts
Normal file
175
extensions/slack/src/send.unfurl.test.ts
Normal 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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user