Merge d421bbeb48 into da71eaebd2
This commit is contained in:
commit
c980f2f8b4
@ -199,7 +199,9 @@ user scopes if you plan to configure a user token.
|
|||||||
"emoji:read",
|
"emoji:read",
|
||||||
"commands",
|
"commands",
|
||||||
"files:read",
|
"files:read",
|
||||||
"files:write"
|
"files:write",
|
||||||
|
"canvases:read",
|
||||||
|
"canvases:write"
|
||||||
],
|
],
|
||||||
"user": [
|
"user": [
|
||||||
"channels:history",
|
"channels:history",
|
||||||
@ -268,10 +270,14 @@ https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overvie
|
|||||||
https://docs.slack.dev/reference/scopes/emoji.read
|
https://docs.slack.dev/reference/scopes/emoji.read
|
||||||
- `files:write` (uploads via `files.uploadV2`)
|
- `files:write` (uploads via `files.uploadV2`)
|
||||||
https://docs.slack.dev/messaging/working-with-files/#upload
|
https://docs.slack.dev/messaging/working-with-files/#upload
|
||||||
|
- `files:read` (download canvas file content via `url_private_download`)
|
||||||
|
- `canvases:read`, `canvases:write` (create/update canvases)
|
||||||
|
|
||||||
### User token scopes (optional, read-only by default)
|
### User token scopes (optional, read-only by default)
|
||||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||||
|
|
||||||
|
**Canvas ingestion notes:** When a message includes a Slack Canvas, Clawdbot attempts to download the canvas file via `files.info` + `url_private_download`. This requires `files:read` and the bot must have access to the canvas file. Missing scopes will show a short diagnostic in the prompt context.
|
||||||
|
|
||||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||||
- `users:read`
|
- `users:read`
|
||||||
@ -325,7 +331,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
|||||||
"messages": true,
|
"messages": true,
|
||||||
"pins": true,
|
"pins": true,
|
||||||
"memberInfo": true,
|
"memberInfo": true,
|
||||||
"emojiList": true
|
"emojiList": true,
|
||||||
|
"canvases": false
|
||||||
},
|
},
|
||||||
"slashCommand": {
|
"slashCommand": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@ -334,7 +341,9 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
|||||||
"ephemeral": true
|
"ephemeral": true
|
||||||
},
|
},
|
||||||
"textChunkLimit": 4000,
|
"textChunkLimit": 4000,
|
||||||
"mediaMaxMb": 20
|
"mediaMaxMb": 20,
|
||||||
|
"canvasMaxMb": 2,
|
||||||
|
"canvasTextMaxChars": 20000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -351,6 +360,7 @@ ack reaction after the bot replies.
|
|||||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||||
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||||
|
- Canvas downloads are capped by `channels.slack.canvasMaxMb` (default 2) and truncated to `channels.slack.canvasTextMaxChars` characters (default 20000).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
By default, OpenClaw replies in the main channel. Use `channels.slack.replyToMode` to control automatic threading:
|
By default, OpenClaw replies in the main channel. Use `channels.slack.replyToMode` to control automatic threading:
|
||||||
|
|||||||
@ -1251,6 +1251,7 @@ Slack action groups (gate `slack` tool actions):
|
|||||||
| pins | enabled | Pin/unpin/list |
|
| pins | enabled | Pin/unpin/list |
|
||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| emojiList | enabled | Custom emoji list |
|
| emojiList | enabled | Custom emoji list |
|
||||||
|
| canvases | disabled | Create/update canvases |
|
||||||
|
|
||||||
### `channels.mattermost` (bot token)
|
### `channels.mattermost` (bot token)
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { handleSlackAction } from "./slack-actions.js";
|
import { handleSlackAction } from "./slack-actions.js";
|
||||||
|
|
||||||
|
const createSlackCanvas = vi.fn(async () => ({}));
|
||||||
const deleteSlackMessage = vi.fn(async () => ({}));
|
const deleteSlackMessage = vi.fn(async () => ({}));
|
||||||
const editSlackMessage = vi.fn(async () => ({}));
|
const editSlackMessage = vi.fn(async () => ({}));
|
||||||
const getSlackMemberInfo = vi.fn(async () => ({}));
|
const getSlackMemberInfo = vi.fn(async () => ({}));
|
||||||
@ -16,8 +17,10 @@ const removeOwnSlackReactions = vi.fn(async () => ["thumbsup"]);
|
|||||||
const removeSlackReaction = vi.fn(async () => ({}));
|
const removeSlackReaction = vi.fn(async () => ({}));
|
||||||
const sendSlackMessage = vi.fn(async () => ({}));
|
const sendSlackMessage = vi.fn(async () => ({}));
|
||||||
const unpinSlackMessage = vi.fn(async () => ({}));
|
const unpinSlackMessage = vi.fn(async () => ({}));
|
||||||
|
const updateSlackCanvas = vi.fn(async () => ({}));
|
||||||
|
|
||||||
vi.mock("../../slack/actions.js", () => ({
|
vi.mock("../../slack/actions.js", () => ({
|
||||||
|
createSlackCanvas: (...args: unknown[]) => createSlackCanvas(...args),
|
||||||
deleteSlackMessage: (...args: unknown[]) => deleteSlackMessage(...args),
|
deleteSlackMessage: (...args: unknown[]) => deleteSlackMessage(...args),
|
||||||
editSlackMessage: (...args: unknown[]) => editSlackMessage(...args),
|
editSlackMessage: (...args: unknown[]) => editSlackMessage(...args),
|
||||||
getSlackMemberInfo: (...args: unknown[]) => getSlackMemberInfo(...args),
|
getSlackMemberInfo: (...args: unknown[]) => getSlackMemberInfo(...args),
|
||||||
@ -31,6 +34,7 @@ vi.mock("../../slack/actions.js", () => ({
|
|||||||
removeSlackReaction: (...args: unknown[]) => removeSlackReaction(...args),
|
removeSlackReaction: (...args: unknown[]) => removeSlackReaction(...args),
|
||||||
sendSlackMessage: (...args: unknown[]) => sendSlackMessage(...args),
|
sendSlackMessage: (...args: unknown[]) => sendSlackMessage(...args),
|
||||||
unpinSlackMessage: (...args: unknown[]) => unpinSlackMessage(...args),
|
unpinSlackMessage: (...args: unknown[]) => unpinSlackMessage(...args),
|
||||||
|
updateSlackCanvas: (...args: unknown[]) => updateSlackCanvas(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("handleSlackAction", () => {
|
describe("handleSlackAction", () => {
|
||||||
@ -124,6 +128,35 @@ describe("handleSlackAction", () => {
|
|||||||
).rejects.toThrow(/Slack reactions are disabled/);
|
).rejects.toThrow(/Slack reactions are disabled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires canvases to be enabled", async () => {
|
||||||
|
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
await expect(
|
||||||
|
handleSlackAction({ action: "createCanvas", title: "Test", content: "hello" }, cfg),
|
||||||
|
).rejects.toThrow(/Slack canvases are disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a canvas when enabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { slack: { botToken: "tok", actions: { canvases: true } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await handleSlackAction({ action: "createCanvas", title: "Test", content: "hello" }, cfg);
|
||||||
|
expect(createSlackCanvas).toHaveBeenCalledWith(
|
||||||
|
{ title: "Test", content: "hello", channelId: undefined },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a canvas by id", async () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { slack: { botToken: "tok", actions: { canvases: true } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
await handleSlackAction({ action: "updateCanvas", canvasId: "C123", content: "update" }, cfg);
|
||||||
|
expect(updateSlackCanvas).toHaveBeenCalledWith(
|
||||||
|
{ canvasId: "C123", channelId: undefined, content: "update", updateMode: undefined },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes threadTs to sendSlackMessage for thread replies", async () => {
|
it("passes threadTs to sendSlackMessage for thread replies", async () => {
|
||||||
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
|
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
|
||||||
await handleSlackAction(
|
await handleSlackAction(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
import {
|
import {
|
||||||
|
createSlackCanvas,
|
||||||
deleteSlackMessage,
|
deleteSlackMessage,
|
||||||
editSlackMessage,
|
editSlackMessage,
|
||||||
getSlackMemberInfo,
|
getSlackMemberInfo,
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
removeSlackReaction,
|
removeSlackReaction,
|
||||||
sendSlackMessage,
|
sendSlackMessage,
|
||||||
unpinSlackMessage,
|
unpinSlackMessage,
|
||||||
|
updateSlackCanvas,
|
||||||
} from "../../slack/actions.js";
|
} from "../../slack/actions.js";
|
||||||
import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js";
|
import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js";
|
||||||
import { withNormalizedTimestamp } from "../date-time.js";
|
import { withNormalizedTimestamp } from "../date-time.js";
|
||||||
@ -25,6 +27,7 @@ const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage",
|
|||||||
|
|
||||||
const reactionsActions = new Set(["react", "reactions"]);
|
const reactionsActions = new Set(["react", "reactions"]);
|
||||||
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
||||||
|
const canvasActions = new Set(["createCanvas", "updateCanvas"]);
|
||||||
|
|
||||||
export type SlackActionContext = {
|
export type SlackActionContext = {
|
||||||
/** Current channel ID for auto-threading. */
|
/** Current channel ID for auto-threading. */
|
||||||
@ -71,6 +74,29 @@ function resolveThreadTsFromContext(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSlackCanvasError(err: unknown): string {
|
||||||
|
const data = err as { data?: { error?: string; needed?: string | string[] } } | undefined;
|
||||||
|
const error = data?.data?.error ?? (err as { error?: string } | undefined)?.error;
|
||||||
|
if (error === "missing_scope") {
|
||||||
|
const needed = data?.data?.needed;
|
||||||
|
const scopes = Array.isArray(needed) ? needed.join(", ") : needed;
|
||||||
|
return scopes
|
||||||
|
? `Slack canvas action failed (missing_scope: ${scopes}). Add scopes and reinstall the Slack app.`
|
||||||
|
: "Slack canvas action failed (missing_scope). Add scopes and reinstall the Slack app.";
|
||||||
|
}
|
||||||
|
if (error === "not_allowed_token_type") {
|
||||||
|
return "Slack canvas action failed (not_allowed_token_type). Use a bot token with canvases:write.";
|
||||||
|
}
|
||||||
|
if (error === "not_in_channel") {
|
||||||
|
return "Slack canvas action failed (not_in_channel). Invite the bot to the channel and retry.";
|
||||||
|
}
|
||||||
|
if (error === "channel_not_found") {
|
||||||
|
return "Slack canvas action failed (channel_not_found).";
|
||||||
|
}
|
||||||
|
if (error && typeof error === "string") return `Slack canvas action failed (${error}).`;
|
||||||
|
return `Slack canvas action failed (${String(err)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSlackAction(
|
export async function handleSlackAction(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
@ -277,6 +303,49 @@ export async function handleSlackAction(
|
|||||||
return jsonResult({ ok: true, pins: normalizedPins });
|
return jsonResult({ ok: true, pins: normalizedPins });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canvasActions.has(action)) {
|
||||||
|
if (!isActionEnabled("canvases", false)) {
|
||||||
|
throw new Error("Slack canvases are disabled.");
|
||||||
|
}
|
||||||
|
if (action === "createCanvas") {
|
||||||
|
const title = readStringParam(params, "title", { required: true });
|
||||||
|
const content = readStringParam(params, "content", { required: true, allowEmpty: true });
|
||||||
|
const channelIdRaw = readStringParam(params, "channelId");
|
||||||
|
const channelId = channelIdRaw ? resolveSlackChannelId(channelIdRaw) : undefined;
|
||||||
|
try {
|
||||||
|
const result = await createSlackCanvas(
|
||||||
|
{ title, content, channelId },
|
||||||
|
writeOpts ?? undefined,
|
||||||
|
);
|
||||||
|
return jsonResult({ ok: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(formatSlackCanvasError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = readStringParam(params, "content", { required: true, allowEmpty: true });
|
||||||
|
const canvasId = readStringParam(params, "canvasId");
|
||||||
|
const channelIdRaw = readStringParam(params, "channelId");
|
||||||
|
const channelId = channelIdRaw ? resolveSlackChannelId(channelIdRaw) : undefined;
|
||||||
|
if (!canvasId && !channelId) {
|
||||||
|
throw new Error("canvasId or channelId required for Slack canvas updates");
|
||||||
|
}
|
||||||
|
const updateMode = readStringParam(params, "updateMode");
|
||||||
|
try {
|
||||||
|
const result = await updateSlackCanvas(
|
||||||
|
{
|
||||||
|
canvasId: canvasId ?? undefined,
|
||||||
|
channelId,
|
||||||
|
content,
|
||||||
|
updateMode: updateMode ?? undefined,
|
||||||
|
},
|
||||||
|
writeOpts ?? undefined,
|
||||||
|
);
|
||||||
|
return jsonResult({ ok: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(formatSlackCanvasError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "memberInfo") {
|
if (action === "memberInfo") {
|
||||||
if (!isActionEnabled("memberInfo")) {
|
if (!isActionEnabled("memberInfo")) {
|
||||||
throw new Error("Slack member info is disabled.");
|
throw new Error("Slack member info is disabled.");
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"timeout",
|
"timeout",
|
||||||
"kick",
|
"kick",
|
||||||
"ban",
|
"ban",
|
||||||
|
"canvas-create",
|
||||||
|
"canvas-update",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];
|
export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number];
|
||||||
|
|||||||
@ -46,6 +46,10 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
|||||||
}
|
}
|
||||||
if (isActionEnabled("memberInfo")) actions.add("member-info");
|
if (isActionEnabled("memberInfo")) actions.add("member-info");
|
||||||
if (isActionEnabled("emojiList")) actions.add("emoji-list");
|
if (isActionEnabled("emojiList")) actions.add("emoji-list");
|
||||||
|
if (isActionEnabled("canvases", false)) {
|
||||||
|
actions.add("canvas-create");
|
||||||
|
actions.add("canvas-update");
|
||||||
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
@ -204,6 +208,40 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "canvas-create") {
|
||||||
|
const title = readStringParam(params, "title", { required: true });
|
||||||
|
const content = readStringParam(params, "content", { required: true, allowEmpty: true });
|
||||||
|
const channelId = readStringParam(params, "channelId");
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "createCanvas",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
channelId: channelId ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "canvas-update") {
|
||||||
|
const content = readStringParam(params, "content", { required: true, allowEmpty: true });
|
||||||
|
const canvasId = readStringParam(params, "canvasId");
|
||||||
|
const channelId = readStringParam(params, "channelId");
|
||||||
|
const updateMode = readStringParam(params, "updateMode");
|
||||||
|
return await handleSlackAction(
|
||||||
|
{
|
||||||
|
action: "updateCanvas",
|
||||||
|
canvasId: canvasId ?? undefined,
|
||||||
|
channelId: channelId ?? undefined,
|
||||||
|
updateMode: updateMode ?? undefined,
|
||||||
|
content,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -469,6 +469,10 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
|
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
|
||||||
"channels.slack.thread.inheritParent":
|
"channels.slack.thread.inheritParent":
|
||||||
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
|
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
|
||||||
|
"channels.slack.canvasMaxMb": "Max MB to download for Slack canvases (default: 2).",
|
||||||
|
"channels.slack.canvasTextMaxChars":
|
||||||
|
"Max characters of Slack canvas content injected into prompts (default: 20000).",
|
||||||
|
"channels.slack.actions.canvases": "Enable Slack canvas actions (default: false).",
|
||||||
"channels.mattermost.botToken":
|
"channels.mattermost.botToken":
|
||||||
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
|
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
|
||||||
"channels.mattermost.baseUrl":
|
"channels.mattermost.baseUrl":
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export type SlackActionConfig = {
|
|||||||
memberInfo?: boolean;
|
memberInfo?: boolean;
|
||||||
channelInfo?: boolean;
|
channelInfo?: boolean;
|
||||||
emojiList?: boolean;
|
emojiList?: boolean;
|
||||||
|
canvases?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackSlashCommandConfig = {
|
export type SlackSlashCommandConfig = {
|
||||||
@ -123,6 +124,10 @@ export type SlackAccountConfig = {
|
|||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
|
/** Max size for downloaded Slack canvas content (MB). Default: 2. */
|
||||||
|
canvasMaxMb?: number;
|
||||||
|
/** Max characters of Slack canvas text injected into prompt (default: 20000). */
|
||||||
|
canvasTextMaxChars?: number;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: SlackReactionNotificationMode;
|
reactionNotifications?: SlackReactionNotificationMode;
|
||||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||||
|
|||||||
@ -429,6 +429,8 @@ export const SlackAccountSchema = z
|
|||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
|
canvasMaxMb: z.number().positive().optional(),
|
||||||
|
canvasTextMaxChars: z.number().int().positive().optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
replyToMode: ReplyToModeSchema.optional(),
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
@ -444,6 +446,7 @@ export const SlackAccountSchema = z
|
|||||||
memberInfo: z.boolean().optional(),
|
memberInfo: z.boolean().optional(),
|
||||||
channelInfo: z.boolean().optional(),
|
channelInfo: z.boolean().optional(),
|
||||||
emojiList: z.boolean().optional(),
|
emojiList: z.boolean().optional(),
|
||||||
|
canvases: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -258,3 +258,49 @@ export async function listSlackPins(
|
|||||||
const result = await client.pins.list({ channel: channelId });
|
const result = await client.pins.list({ channel: channelId });
|
||||||
return (result.items ?? []) as SlackPin[];
|
return (result.items ?? []) as SlackPin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SlackCanvasCreatePayload = {
|
||||||
|
title: string;
|
||||||
|
document_content: string;
|
||||||
|
channel_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlackCanvasEditPayload = {
|
||||||
|
canvas_id?: string;
|
||||||
|
channel_id?: string;
|
||||||
|
document_content: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createSlackCanvas(
|
||||||
|
params: { title: string; content: string; channelId?: string },
|
||||||
|
opts: SlackActionClientOpts = {},
|
||||||
|
) {
|
||||||
|
const client = await getClient(opts);
|
||||||
|
const payload: SlackCanvasCreatePayload = {
|
||||||
|
title: params.title,
|
||||||
|
document_content: params.content,
|
||||||
|
...(params.channelId ? { channel_id: params.channelId } : {}),
|
||||||
|
};
|
||||||
|
if (params.channelId) {
|
||||||
|
return await client.apiCall("conversations.canvases.create", payload);
|
||||||
|
}
|
||||||
|
return await client.apiCall("canvases.create", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlackCanvas(
|
||||||
|
params: { canvasId?: string; channelId?: string; content: string; updateMode?: string },
|
||||||
|
opts: SlackActionClientOpts = {},
|
||||||
|
) {
|
||||||
|
const client = await getClient(opts);
|
||||||
|
const payload: SlackCanvasEditPayload = {
|
||||||
|
...(params.canvasId ? { canvas_id: params.canvasId } : {}),
|
||||||
|
...(params.channelId ? { channel_id: params.channelId } : {}),
|
||||||
|
document_content: params.content,
|
||||||
|
...(params.updateMode ? { mode: params.updateMode } : {}),
|
||||||
|
};
|
||||||
|
if (params.channelId && !params.canvasId) {
|
||||||
|
return await client.apiCall("conversations.canvases.edit", payload);
|
||||||
|
}
|
||||||
|
return await client.apiCall("canvases.edit", payload);
|
||||||
|
}
|
||||||
|
|||||||
156
src/slack/canvases.test.ts
Normal file
156
src/slack/canvases.test.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { WebClient } from "@slack/web-api";
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractCanvasRefsFromEvent,
|
||||||
|
fetchSlackCanvasContent,
|
||||||
|
isSlackCanvasFile,
|
||||||
|
} from "./canvases.js";
|
||||||
|
import type { SlackMessageEvent } from "./types.js";
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
return {
|
||||||
|
files: {
|
||||||
|
info: vi.fn(async () => ({
|
||||||
|
file: {
|
||||||
|
id: "F123",
|
||||||
|
title: "Canvas Title",
|
||||||
|
url_private_download: "https://files.slack.com/canvas.json",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as unknown as WebClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isSlackCanvasFile", () => {
|
||||||
|
it("detects canvas mimetype", () => {
|
||||||
|
expect(isSlackCanvasFile({ mimetype: "application/vnd.slack-docs" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects canvas pretty_type", () => {
|
||||||
|
expect(isSlackCanvasFile({ pretty_type: "Canvas" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects quip filetype", () => {
|
||||||
|
expect(isSlackCanvasFile({ filetype: "quip" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractCanvasRefsFromEvent", () => {
|
||||||
|
it("finds canvas files and URLs", () => {
|
||||||
|
const event: SlackMessageEvent = {
|
||||||
|
type: "message",
|
||||||
|
channel: "C1",
|
||||||
|
ts: "1.2",
|
||||||
|
text: "See https://acme.slack.com/docs/T123/F12345678",
|
||||||
|
files: [{ id: "F999", mimetype: "application/vnd.slack-docs" }],
|
||||||
|
} as SlackMessageEvent;
|
||||||
|
|
||||||
|
const refs = extractCanvasRefsFromEvent(event);
|
||||||
|
const ids = refs.map((ref) => ref.fileId).filter(Boolean);
|
||||||
|
expect(ids).toContain("F999");
|
||||||
|
expect(ids).toContain("F12345678");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchSlackCanvasContent", () => {
|
||||||
|
it("downloads and parses JSON canvas content", async () => {
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ text: "hello canvas" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
const result = await fetchSlackCanvasContent({
|
||||||
|
ref: { fileId: "F123" },
|
||||||
|
client,
|
||||||
|
token: "xoxb-test",
|
||||||
|
maxBytes: 1024,
|
||||||
|
maxChars: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.extractedText).toContain("hello canvas");
|
||||||
|
expect(result.rawFormat).toBe("json");
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns missing_scope errors", async () => {
|
||||||
|
const client = {
|
||||||
|
files: {
|
||||||
|
info: vi.fn(async () => {
|
||||||
|
const err = new Error("missing_scope");
|
||||||
|
(err as any).data = { error: "missing_scope", needed: "files:read" };
|
||||||
|
throw err;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as WebClient;
|
||||||
|
|
||||||
|
const result = await fetchSlackCanvasContent({
|
||||||
|
ref: { fileId: "F123" },
|
||||||
|
client,
|
||||||
|
token: "xoxb-test",
|
||||||
|
maxBytes: 1024,
|
||||||
|
maxChars: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toContain("missing_scope");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles download HTTP failures", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => new Response("forbidden", { status: 403 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
const result = await fetchSlackCanvasContent({
|
||||||
|
ref: { fileId: "F123" },
|
||||||
|
client,
|
||||||
|
token: "xoxb-test",
|
||||||
|
maxBytes: 1024,
|
||||||
|
maxChars: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toContain("download_http_403");
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates large payloads", async () => {
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ text: "1234567890" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const client = createClient();
|
||||||
|
const result = await fetchSlackCanvasContent({
|
||||||
|
ref: { fileId: "F123" },
|
||||||
|
client,
|
||||||
|
token: "xoxb-test",
|
||||||
|
maxBytes: 1024,
|
||||||
|
maxChars: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.truncated).toBe(true);
|
||||||
|
expect(result.extractedText.length).toBeLessThanOrEqual(5);
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
284
src/slack/canvases.ts
Normal file
284
src/slack/canvases.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||||
|
|
||||||
|
import { logVerbose } from "../globals.js";
|
||||||
|
import type { SlackFile, SlackMessageEvent } from "./types.js";
|
||||||
|
|
||||||
|
type CanvasUrlMatch = {
|
||||||
|
url: string;
|
||||||
|
fileId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlackCanvasRef = {
|
||||||
|
fileId?: string;
|
||||||
|
canvasUrl?: string;
|
||||||
|
channelId?: string;
|
||||||
|
messageTs?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlackCanvasContent = {
|
||||||
|
title?: string;
|
||||||
|
extractedText: string;
|
||||||
|
rawFormat: "json" | "text" | "unknown";
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlackCanvasFetchResult =
|
||||||
|
| ({ ok: true } & SlackCanvasContent)
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const SLACK_CANVAS_MIME = "application/vnd.slack-docs";
|
||||||
|
const CANVAS_FILETYPE_HINTS = new Set(["quip"]);
|
||||||
|
const CANVAS_PRETTY_TYPE = new Set(["canvas"]);
|
||||||
|
|
||||||
|
const CANVAS_URL_RE = /https?:\/\/[^\s<>"]*slack\.com\/(docs|canvas|doc|files)\/[^\s<>"]+/gi;
|
||||||
|
const SLACK_FILE_ID_RE = /\bF[A-Z0-9]{8,}\b/g;
|
||||||
|
|
||||||
|
export function isSlackCanvasFile(file?: SlackFile | null): boolean {
|
||||||
|
if (!file) return false;
|
||||||
|
const mimetype = file.mimetype?.toLowerCase();
|
||||||
|
const prettyType = file.pretty_type?.toLowerCase();
|
||||||
|
const filetype = file.filetype?.toLowerCase();
|
||||||
|
if (mimetype && mimetype === SLACK_CANVAS_MIME) return true;
|
||||||
|
if (prettyType && CANVAS_PRETTY_TYPE.has(prettyType)) return true;
|
||||||
|
if (filetype && CANVAS_FILETYPE_HINTS.has(filetype)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFileIdFromUrl(url: string): string | undefined {
|
||||||
|
const matches = url.match(SLACK_FILE_ID_RE);
|
||||||
|
if (!matches || matches.length === 0) return undefined;
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUrlsFromText(text: string): CanvasUrlMatch[] {
|
||||||
|
const matches = text.match(CANVAS_URL_RE) ?? [];
|
||||||
|
return matches.map((url) => ({ url, fileId: extractFileIdFromUrl(url) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUrlsFromObject(value: unknown, into: CanvasUrlMatch[], seen = new Set<string>()) {
|
||||||
|
if (!value) return;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
for (const entry of collectUrlsFromText(value)) {
|
||||||
|
if (seen.has(entry.url)) continue;
|
||||||
|
seen.add(entry.url);
|
||||||
|
into.push(entry);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) collectUrlsFromObject(entry, into, seen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") return;
|
||||||
|
for (const entry of Object.values(value as Record<string, unknown>)) {
|
||||||
|
collectUrlsFromObject(entry, into, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCanvasRefsFromEvent(event: SlackMessageEvent): SlackCanvasRef[] {
|
||||||
|
const refs: SlackCanvasRef[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const channelId = event.channel;
|
||||||
|
const messageTs = event.ts ?? event.event_ts;
|
||||||
|
|
||||||
|
for (const file of event.files ?? []) {
|
||||||
|
if (!isSlackCanvasFile(file)) continue;
|
||||||
|
const fileId = file.id;
|
||||||
|
if (fileId && seen.has(fileId)) continue;
|
||||||
|
if (fileId) seen.add(fileId);
|
||||||
|
refs.push({ fileId, channelId, messageTs });
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMatches: CanvasUrlMatch[] = [];
|
||||||
|
if (event.text) collectUrlsFromObject(event.text, urlMatches);
|
||||||
|
collectUrlsFromObject(event.blocks, urlMatches);
|
||||||
|
collectUrlsFromObject(event.attachments, urlMatches);
|
||||||
|
|
||||||
|
for (const match of urlMatches) {
|
||||||
|
const dedupeKey = match.fileId ?? match.url;
|
||||||
|
if (seen.has(dedupeKey)) continue;
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
refs.push({
|
||||||
|
fileId: match.fileId,
|
||||||
|
canvasUrl: match.url,
|
||||||
|
channelId,
|
||||||
|
messageTs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildErrorLabel(err: unknown): string {
|
||||||
|
const data = err as { data?: { error?: string; needed?: string | string[] } } | undefined;
|
||||||
|
const apiError = data?.data?.error ?? (err as { error?: string } | undefined)?.error;
|
||||||
|
if (apiError === "missing_scope") {
|
||||||
|
const needed = data?.data?.needed;
|
||||||
|
const scopeList = Array.isArray(needed) ? needed.join(", ") : needed;
|
||||||
|
return scopeList ? `missing_scope: ${scopeList}` : "missing_scope";
|
||||||
|
}
|
||||||
|
if (apiError === "not_allowed_token_type") return "not_allowed_token_type";
|
||||||
|
if (apiError === "not_in_channel") return "not_in_channel";
|
||||||
|
if (apiError === "channel_not_found") return "channel_not_found";
|
||||||
|
if (apiError && typeof apiError === "string") return apiError;
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceToText(input: string, maxChars: number): { text: string; truncated: boolean } {
|
||||||
|
if (input.length <= maxChars) return { text: input, truncated: false };
|
||||||
|
return { text: input.slice(0, maxChars), truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectJsonText(
|
||||||
|
value: unknown,
|
||||||
|
params: {
|
||||||
|
maxChars: number;
|
||||||
|
results: string[];
|
||||||
|
seen: Set<string>;
|
||||||
|
depth: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (params.results.join("\n").length >= params.maxChars) return;
|
||||||
|
if (params.depth > 12) return;
|
||||||
|
if (value == null) return;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (!params.seen.has(trimmed)) {
|
||||||
|
params.seen.add(trimmed);
|
||||||
|
params.results.push(trimmed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
collectJsonText(entry, { ...params, depth: params.depth + 1 });
|
||||||
|
if (params.results.join("\n").length >= params.maxChars) return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") return;
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const preferredKeys = ["text", "plain_text", "title", "value", "name"];
|
||||||
|
for (const key of preferredKeys) {
|
||||||
|
const entry = record[key];
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
collectJsonText(entry, { ...params, depth: params.depth + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of Object.values(record)) {
|
||||||
|
collectJsonText(entry, { ...params, depth: params.depth + 1 });
|
||||||
|
if (params.results.join("\n").length >= params.maxChars) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromJson(
|
||||||
|
payload: unknown,
|
||||||
|
maxChars: number,
|
||||||
|
): { text: string; truncated: boolean } {
|
||||||
|
const results: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
collectJsonText(payload, { maxChars, results, seen, depth: 0 });
|
||||||
|
const joined = results.join("\n");
|
||||||
|
if (!joined) {
|
||||||
|
return coerceToText(JSON.stringify(payload), maxChars);
|
||||||
|
}
|
||||||
|
return coerceToText(joined, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSlackCanvasContent(params: {
|
||||||
|
ref: SlackCanvasRef;
|
||||||
|
client: SlackWebClient;
|
||||||
|
token: string;
|
||||||
|
maxBytes: number;
|
||||||
|
maxChars: number;
|
||||||
|
}): Promise<SlackCanvasFetchResult> {
|
||||||
|
const { ref, client, token, maxBytes, maxChars } = params;
|
||||||
|
const fileId = ref.fileId ?? (ref.canvasUrl ? extractFileIdFromUrl(ref.canvasUrl) : undefined);
|
||||||
|
if (!fileId) {
|
||||||
|
return { ok: false, error: "no_file_id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileInfo: {
|
||||||
|
file?: SlackFile & {
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
url_private?: string;
|
||||||
|
url_private_download?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fileInfo = (await client.files.info({ file: fileId })) as {
|
||||||
|
file?: SlackFile & {
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
url_private?: string;
|
||||||
|
url_private_download?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const label = buildErrorLabel(err);
|
||||||
|
logVerbose(`slack canvases: files.info failed fileId=${fileId} error=${label}`);
|
||||||
|
return { ok: false, error: label };
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fileInfo.file;
|
||||||
|
if (!file) {
|
||||||
|
return { ok: false, error: "file_not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = file.url_private_download ?? file.url_private;
|
||||||
|
if (!url) {
|
||||||
|
return { ok: false, error: "missing_download_url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: `download_failed: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, error: `download_http_${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const truncatedByBytes = buffer.byteLength > maxBytes;
|
||||||
|
const sliced = truncatedByBytes ? buffer.slice(0, maxBytes) : buffer;
|
||||||
|
const text = new TextDecoder().decode(sliced);
|
||||||
|
|
||||||
|
let extracted: { text: string; truncated: boolean };
|
||||||
|
let rawFormat: SlackCanvasContent["rawFormat"] = "unknown";
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType.includes("application/json") ||
|
||||||
|
text.trim().startsWith("{") ||
|
||||||
|
text.trim().startsWith("[")
|
||||||
|
) {
|
||||||
|
rawFormat = "json";
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
extracted = extractTextFromJson(payload, maxChars);
|
||||||
|
} catch {
|
||||||
|
extracted = coerceToText(text, maxChars);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawFormat = "text";
|
||||||
|
extracted = coerceToText(text, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
title: file.title ?? file.name ?? fileId,
|
||||||
|
extractedText: extracted.text,
|
||||||
|
rawFormat,
|
||||||
|
truncated: truncatedByBytes || extracted.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ const baseParams = () => ({
|
|||||||
reactionMode: "off" as const,
|
reactionMode: "off" as const,
|
||||||
reactionAllowlist: [],
|
reactionAllowlist: [],
|
||||||
replyToMode: "off" as const,
|
replyToMode: "off" as const,
|
||||||
|
threadHistoryScope: "thread" as const,
|
||||||
|
threadInheritParent: false,
|
||||||
slashCommand: {
|
slashCommand: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
name: "openclaw",
|
name: "openclaw",
|
||||||
@ -37,6 +39,8 @@ const baseParams = () => ({
|
|||||||
textLimit: 4000,
|
textLimit: 4000,
|
||||||
ackReactionScope: "group-mentions",
|
ackReactionScope: "group-mentions",
|
||||||
mediaMaxBytes: 1,
|
mediaMaxBytes: 1,
|
||||||
|
canvasMaxBytes: 1,
|
||||||
|
canvasTextMaxChars: 1000,
|
||||||
removeAckAfterReply: false,
|
removeAckAfterReply: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,8 @@ export type SlackMonitorContext = {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
ackReactionScope: string;
|
ackReactionScope: string;
|
||||||
mediaMaxBytes: number;
|
mediaMaxBytes: number;
|
||||||
|
canvasMaxBytes: number;
|
||||||
|
canvasTextMaxChars: number;
|
||||||
removeAckAfterReply: boolean;
|
removeAckAfterReply: boolean;
|
||||||
|
|
||||||
logger: ReturnType<typeof getChildLogger>;
|
logger: ReturnType<typeof getChildLogger>;
|
||||||
@ -147,6 +149,8 @@ export function createSlackMonitorContext(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
ackReactionScope: string;
|
ackReactionScope: string;
|
||||||
mediaMaxBytes: number;
|
mediaMaxBytes: number;
|
||||||
|
canvasMaxBytes: number;
|
||||||
|
canvasTextMaxChars: number;
|
||||||
removeAckAfterReply: boolean;
|
removeAckAfterReply: boolean;
|
||||||
}): SlackMonitorContext {
|
}): SlackMonitorContext {
|
||||||
const channelHistories = new Map<string, HistoryEntry[]>();
|
const channelHistories = new Map<string, HistoryEntry[]>();
|
||||||
@ -391,6 +395,8 @@ export function createSlackMonitorContext(params: {
|
|||||||
textLimit: params.textLimit,
|
textLimit: params.textLimit,
|
||||||
ackReactionScope: params.ackReactionScope,
|
ackReactionScope: params.ackReactionScope,
|
||||||
mediaMaxBytes: params.mediaMaxBytes,
|
mediaMaxBytes: params.mediaMaxBytes,
|
||||||
|
canvasMaxBytes: params.canvasMaxBytes,
|
||||||
|
canvasTextMaxChars: params.canvasTextMaxChars,
|
||||||
removeAckAfterReply: params.removeAckAfterReply,
|
removeAckAfterReply: params.removeAckAfterReply,
|
||||||
logger,
|
logger,
|
||||||
markMessageSeen,
|
markMessageSeen,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { App } from "@slack/bolt";
|
import type { App } from "@slack/bolt";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
import type { RuntimeEnv } from "../../../runtime.js";
|
import type { RuntimeEnv } from "../../../runtime.js";
|
||||||
@ -47,6 +47,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
textLimit: 4000,
|
textLimit: 4000,
|
||||||
ackReactionScope: "group-mentions",
|
ackReactionScope: "group-mentions",
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
|
canvasMaxBytes: 1024,
|
||||||
|
canvasTextMaxChars: 2000,
|
||||||
removeAckAfterReply: false,
|
removeAckAfterReply: false,
|
||||||
});
|
});
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||||
@ -115,6 +117,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
textLimit: 4000,
|
textLimit: 4000,
|
||||||
ackReactionScope: "group-mentions",
|
ackReactionScope: "group-mentions",
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
|
canvasMaxBytes: 1024,
|
||||||
|
canvasTextMaxChars: 2000,
|
||||||
removeAckAfterReply: false,
|
removeAckAfterReply: false,
|
||||||
});
|
});
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||||
@ -145,4 +149,94 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
expect(prepared).toBeTruthy();
|
expect(prepared).toBeTruthy();
|
||||||
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
|
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends canvas content to the inbound body", async () => {
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ title: "Canvas", text: "hello canvas" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const filesInfo = vi.fn(async () => ({
|
||||||
|
file: {
|
||||||
|
id: "F12345678",
|
||||||
|
title: "Project Canvas",
|
||||||
|
url_private_download: "https://files.slack.com/canvas.json",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const slackCtx = createSlackMonitorContext({
|
||||||
|
cfg: {
|
||||||
|
channels: { slack: { enabled: true } },
|
||||||
|
} as ClawdbotConfig,
|
||||||
|
accountId: "default",
|
||||||
|
botToken: "token",
|
||||||
|
app: { client: { files: { info: filesInfo } } } as App,
|
||||||
|
runtime: {} as RuntimeEnv,
|
||||||
|
botUserId: "B1",
|
||||||
|
teamId: "T1",
|
||||||
|
apiAppId: "A1",
|
||||||
|
historyLimit: 0,
|
||||||
|
sessionScope: "per-sender",
|
||||||
|
mainKey: "main",
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: [],
|
||||||
|
groupDmEnabled: true,
|
||||||
|
groupDmChannels: [],
|
||||||
|
defaultRequireMention: true,
|
||||||
|
groupPolicy: "open",
|
||||||
|
useAccessGroups: false,
|
||||||
|
reactionMode: "off",
|
||||||
|
reactionAllowlist: [],
|
||||||
|
replyToMode: "off",
|
||||||
|
threadHistoryScope: "thread",
|
||||||
|
threadInheritParent: false,
|
||||||
|
slashCommand: {
|
||||||
|
enabled: false,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "slack:slash",
|
||||||
|
ephemeral: true,
|
||||||
|
},
|
||||||
|
textLimit: 4000,
|
||||||
|
ackReactionScope: "group-mentions",
|
||||||
|
mediaMaxBytes: 1024,
|
||||||
|
canvasMaxBytes: 1024,
|
||||||
|
canvasTextMaxChars: 2000,
|
||||||
|
removeAckAfterReply: false,
|
||||||
|
});
|
||||||
|
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||||
|
|
||||||
|
const account: ResolvedSlackAccount = {
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
botTokenSource: "config",
|
||||||
|
appTokenSource: "config",
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message: SlackMessageEvent = {
|
||||||
|
channel: "D123",
|
||||||
|
channel_type: "im",
|
||||||
|
user: "U1",
|
||||||
|
text: "see canvas",
|
||||||
|
ts: "1.000",
|
||||||
|
files: [{ id: "F12345678", mimetype: "application/vnd.slack-docs" }],
|
||||||
|
} as SlackMessageEvent;
|
||||||
|
|
||||||
|
const prepared = await prepareSlackMessage({
|
||||||
|
ctx: slackCtx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
opts: { source: "message" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepared).toBeTruthy();
|
||||||
|
expect(prepared!.ctxPayload.Body).toContain("[Slack Canvas: Project Canvas]");
|
||||||
|
expect(prepared!.ctxPayload.Body).toContain("hello canvas");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -44,6 +44,7 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
|
|||||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||||
import { resolveSlackChannelConfig } from "../channel-config.js";
|
import { resolveSlackChannelConfig } from "../channel-config.js";
|
||||||
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
|
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js";
|
||||||
|
import { extractCanvasRefsFromEvent, fetchSlackCanvasContent } from "../../canvases.js";
|
||||||
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js";
|
||||||
|
|
||||||
import type { PreparedSlackMessage } from "./types.js";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
@ -336,8 +337,45 @@ export async function prepareSlackMessage(params: {
|
|||||||
token: ctx.botToken,
|
token: ctx.botToken,
|
||||||
maxBytes: ctx.mediaMaxBytes,
|
maxBytes: ctx.mediaMaxBytes,
|
||||||
});
|
});
|
||||||
const rawBody = (message.text ?? "").trim() || media?.placeholder || "";
|
|
||||||
if (!rawBody) return null;
|
const canvasRefs = extractCanvasRefsFromEvent(message);
|
||||||
|
const canvasBlocks: string[] = [];
|
||||||
|
for (const ref of canvasRefs) {
|
||||||
|
const label = ref.fileId ?? ref.canvasUrl ?? "unknown";
|
||||||
|
const result = await fetchSlackCanvasContent({
|
||||||
|
ref,
|
||||||
|
client: ctx.app.client,
|
||||||
|
token: ctx.botToken,
|
||||||
|
maxBytes: ctx.canvasMaxBytes,
|
||||||
|
maxChars: ctx.canvasTextMaxChars,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
const title = result.title ?? label;
|
||||||
|
const content = result.truncated
|
||||||
|
? `${result.extractedText}\n[truncated]`
|
||||||
|
: result.extractedText;
|
||||||
|
canvasBlocks.push(`[Slack Canvas: ${title}]\n${content}`);
|
||||||
|
} else {
|
||||||
|
ctx.logger.warn(
|
||||||
|
{
|
||||||
|
fileId: ref.fileId,
|
||||||
|
url: ref.canvasUrl,
|
||||||
|
channelId: ref.channelId,
|
||||||
|
messageTs: ref.messageTs,
|
||||||
|
error: result.error,
|
||||||
|
},
|
||||||
|
"slack canvas fetch failed",
|
||||||
|
);
|
||||||
|
canvasBlocks.push(`[Slack Canvas: ${label}] Unable to fetch content (${result.error})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const canvasContext = canvasBlocks.length > 0 ? canvasBlocks.join("\n\n") : "";
|
||||||
|
|
||||||
|
const baseBody =
|
||||||
|
(message.text ?? "").trim() || media?.placeholder || (canvasContext ? "[Slack canvas]" : "");
|
||||||
|
if (!baseBody) return null;
|
||||||
|
const rawBody = baseBody;
|
||||||
|
const rawBodyWithCanvas = canvasContext ? `${rawBody}\n\n${canvasContext}` : rawBody;
|
||||||
|
|
||||||
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
const ackReaction = resolveAckReaction(cfg, route.agentId);
|
||||||
const ackReactionValue = ackReaction ?? "";
|
const ackReactionValue = ackReaction ?? "";
|
||||||
@ -395,7 +433,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
GroupSubject: isRoomish ? roomLabel : undefined,
|
GroupSubject: isRoomish ? roomLabel : undefined,
|
||||||
From: slackFrom,
|
From: slackFrom,
|
||||||
}) ?? (isDirectMessage ? senderName : roomLabel);
|
}) ?? (isDirectMessage ? senderName : roomLabel);
|
||||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
const textWithId = `${rawBodyWithCanvas}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||||
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -122,6 +122,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||||
|
const canvasMaxBytes = (slackCfg.canvasMaxMb ?? 2) * 1024 * 1024;
|
||||||
|
const canvasTextMaxChars = slackCfg.canvasTextMaxChars ?? 20000;
|
||||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||||
|
|
||||||
const receiver =
|
const receiver =
|
||||||
@ -203,6 +205,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
textLimit,
|
textLimit,
|
||||||
ackReactionScope,
|
ackReactionScope,
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
|
canvasMaxBytes,
|
||||||
|
canvasTextMaxChars,
|
||||||
removeAckAfterReply,
|
removeAckAfterReply,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
export type SlackFile = {
|
export type SlackFile = {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
title?: string;
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
|
filetype?: string;
|
||||||
|
pretty_type?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
url_private?: string;
|
url_private?: string;
|
||||||
url_private_download?: string;
|
url_private_download?: string;
|
||||||
@ -21,6 +24,8 @@ export type SlackMessageEvent = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
channel_type?: "im" | "mpim" | "channel" | "group";
|
channel_type?: "im" | "mpim" | "channel" | "group";
|
||||||
files?: SlackFile[];
|
files?: SlackFile[];
|
||||||
|
blocks?: unknown[];
|
||||||
|
attachments?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackAppMentionEvent = {
|
export type SlackAppMentionEvent = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user