mirror of
https://github.com/stablyai/agent-slack.git
synced 2026-09-19 01:06:54 +08:00
f7ed471090
[Background] Slack unfurls text and media links by default, while the CLI exposed no per-message preview control. https://docs.slack.dev/messaging/unfurling-links-in-messages/ Native Slack drafts and message edits do not expose the same send-time controls. File uploads reject the flag because their API accepts no unfurl fields. [Verification] - [x] `mise x bun@1.3.9 -- bun test test/message-send.test.ts test/safe-mode.test.ts test/help-contracts.test.ts` - 74 tests passed - [x] `mise x bun@1.3.9 -- bun test` - 351 tests passed - [x] `mise x bun@1.3.9 -- bun run typecheck` - passed AI-assistant: Claude Fable 5, GPT-5.6-sol
1153 lines
36 KiB
TypeScript
1153 lines
36 KiB
TypeScript
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { CliContext } from "../src/cli/context.ts";
|
|
import { composeMessage } from "../src/cli/compose-actions.ts";
|
|
import { editMessage, sendMessage } from "../src/cli/message-actions.ts";
|
|
import {
|
|
cancelScheduledMessage,
|
|
listScheduledMessages,
|
|
} from "../src/cli/message-scheduled-actions.ts";
|
|
import {
|
|
parseAbsoluteSchedule,
|
|
parseRelativeSchedule,
|
|
resolveSchedulePostAt,
|
|
} from "../src/slack/scheduled-messages.ts";
|
|
import { withEnvironment } from "./helpers/environment.ts";
|
|
|
|
function createContext(
|
|
calls: { method: string; params: Record<string, unknown> }[],
|
|
fixtures: { historyMessages?: Record<string, unknown>[] } = {},
|
|
) {
|
|
const client = {
|
|
api: async (method: string, params: Record<string, unknown>) => {
|
|
calls.push({ method, params });
|
|
if (method === "files.getUploadURLExternal") {
|
|
return { ok: true, upload_url: "https://upload.example/file", file_id: "F123" };
|
|
}
|
|
if (method === "chat.postMessage") {
|
|
return { ok: true, channel: String(params.channel), ts: "1770165109.628379" };
|
|
}
|
|
if (method === "chat.scheduleMessage") {
|
|
return {
|
|
ok: true,
|
|
channel: String(params.channel),
|
|
scheduled_message_id: "Q1234ABCD",
|
|
post_at: String(params.post_at),
|
|
};
|
|
}
|
|
if (method === "chat.scheduledMessages.list") {
|
|
return {
|
|
ok: true,
|
|
scheduled_messages: [
|
|
{
|
|
id: "Q1234ABCD",
|
|
channel_id: String(params.channel ?? "C12345678"),
|
|
post_at: 1770168709,
|
|
text: "scheduled",
|
|
},
|
|
],
|
|
response_metadata: { next_cursor: "next-1" },
|
|
};
|
|
}
|
|
if (method === "chat.deleteScheduledMessage") {
|
|
return { ok: true };
|
|
}
|
|
if (method === "search.messages") {
|
|
return {
|
|
ok: true,
|
|
messages: { matches: [{ channel: { id: "C12345678", name: "general" } }] },
|
|
};
|
|
}
|
|
if (method === "conversations.open") {
|
|
return { ok: true, channel: { id: "D12345678" } };
|
|
}
|
|
if (method === "conversations.history") {
|
|
return { ok: true, messages: fixtures.historyMessages ?? [] };
|
|
}
|
|
return { ok: true };
|
|
},
|
|
};
|
|
|
|
return {
|
|
effectiveWorkspaceUrl: (flag?: string) => flag,
|
|
assertWorkspaceSpecifiedForChannelNames: async () => {},
|
|
withAutoRefresh: async <T>(input: {
|
|
workspaceUrl: string | undefined;
|
|
work: () => Promise<T>;
|
|
}) => input.work(),
|
|
getClientForWorkspace: async () => ({
|
|
client: client as never,
|
|
auth: { auth_type: "standard", token: "x" as const },
|
|
workspace_url: "https://workspace.slack.com",
|
|
}),
|
|
normalizeUrl: (u: string) => u,
|
|
errorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
|
|
parseContentType: () => "any",
|
|
parseCurl: () => ({
|
|
workspace_url: "https://workspace.slack.com",
|
|
xoxc_token: "xoxc-1",
|
|
xoxd_cookie: "xoxd-1",
|
|
}),
|
|
importDesktop: async () => ({
|
|
cookie_d: "",
|
|
teams: [],
|
|
source: { leveldb_path: "", cookies_path: "" },
|
|
}),
|
|
importChrome: () => ({ cookie_d: "", teams: [] }),
|
|
importBrave: async () => null,
|
|
importFirefox: async () => null,
|
|
} satisfies CliContext;
|
|
}
|
|
|
|
describe("sendMessage", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test("posts a normal message when no attachments are passed", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "hello",
|
|
options: { workspace: "https://workspace.slack.com" },
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "hello",
|
|
thread_ts: undefined,
|
|
},
|
|
},
|
|
]);
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
channel_id: "C12345678",
|
|
ts: "1770165109.628379",
|
|
thread_ts: undefined,
|
|
permalink: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
|
|
});
|
|
});
|
|
|
|
test("--no-unfurl disables link and media previews for immediate sends", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "https://example.com",
|
|
options: { unfurl: false },
|
|
});
|
|
|
|
expect(calls[0]).toEqual({
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "https://example.com",
|
|
thread_ts: undefined,
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("opens and sends to a DM for a W-prefixed user target", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "W12345678",
|
|
text: "hello",
|
|
options: {},
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "conversations.open",
|
|
params: { users: "W12345678" },
|
|
},
|
|
{
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "D12345678",
|
|
text: "hello",
|
|
thread_ts: undefined,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("returns a permalink when the workspace was resolved implicitly", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "hello",
|
|
options: {},
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "hello",
|
|
thread_ts: undefined,
|
|
},
|
|
},
|
|
]);
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
channel_id: "C12345678",
|
|
ts: "1770165109.628379",
|
|
thread_ts: undefined,
|
|
permalink: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
|
|
});
|
|
});
|
|
|
|
test("threaded reply returns thread_ts distinct from ts", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "reply",
|
|
options: { threadTs: "1770160000.000001" },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
channel_id: "C12345678",
|
|
ts: "1770165109.628379",
|
|
thread_ts: "1770160000.000001",
|
|
permalink:
|
|
"https://workspace.slack.com/archives/C12345678/p1770165109628379?thread_ts=1770160000.000001&cid=C12345678",
|
|
});
|
|
});
|
|
|
|
test("--reply-broadcast with --thread-ts sends reply_broadcast: true", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "shipping today",
|
|
options: { threadTs: "1770160000.000001", replyBroadcast: true },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.postMessage");
|
|
expect(calls[0]?.params.reply_broadcast).toBe(true);
|
|
expect(calls[0]?.params.thread_ts).toBe("1770160000.000001");
|
|
});
|
|
|
|
test("--reply-broadcast omits the field from chat.postMessage when flag is absent", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "reply",
|
|
options: { threadTs: "1770160000.000001" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect("reply_broadcast" in (calls[0]?.params ?? {})).toBe(false);
|
|
});
|
|
|
|
test("--reply-broadcast without --thread-ts throws", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "oops",
|
|
options: { replyBroadcast: true },
|
|
}),
|
|
).rejects.toThrow(/--reply-broadcast requires --thread-ts/);
|
|
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
test("--reply-broadcast on a URL target broadcasts using the message's derived thread_ts", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls, {
|
|
historyMessages: [{ ts: "1770160500.000100", thread_ts: "1770160000.000001", text: "root" }],
|
|
});
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "https://workspace.slack.com/archives/C12345678/p1770160500000100",
|
|
text: "broadcast",
|
|
options: { replyBroadcast: true },
|
|
});
|
|
|
|
const post = calls.find((c) => c.method === "chat.postMessage");
|
|
expect(post).toBeDefined();
|
|
expect(post?.params.reply_broadcast).toBe(true);
|
|
expect(post?.params.thread_ts).toBe("1770160000.000001");
|
|
});
|
|
|
|
test("--reply-broadcast on a DM target throws (broadcasting is not meaningful for DMs)", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "U05BRPTKL6A",
|
|
text: "hi",
|
|
options: { threadTs: "1770160000.000001", replyBroadcast: true },
|
|
}),
|
|
).rejects.toThrow(/--reply-broadcast is not supported for DM targets/);
|
|
|
|
expect(calls.some((c) => c.method === "chat.postMessage")).toBe(false);
|
|
expect(calls.some((c) => c.method === "conversations.open")).toBe(false);
|
|
});
|
|
|
|
test("--reply-broadcast works alongside --blocks payload", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [{ type: "section", text: { type: "mrkdwn", text: "summary" } }];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "fallback",
|
|
options: {
|
|
threadTs: "1770160000.000001",
|
|
replyBroadcast: true,
|
|
blocks: blocksPath,
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.postMessage");
|
|
expect(calls[0]?.params).toEqual({
|
|
channel: "C12345678",
|
|
text: "fallback",
|
|
thread_ts: "1770160000.000001",
|
|
blocks,
|
|
reply_broadcast: true,
|
|
});
|
|
});
|
|
|
|
test("uploads attachment and uses message text as initial comment", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const filePath = join(dir, "report.md");
|
|
await writeFile(filePath, "# report\n");
|
|
|
|
const fetchMock = mock(async () => new Response("", { status: 200 }));
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "here's the report",
|
|
options: { attach: [filePath] },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls[0]?.method).toBe("files.getUploadURLExternal");
|
|
expect(calls[1]?.method).toBe("files.completeUploadExternal");
|
|
expect(calls[1]?.params.initial_comment).toBe("here's the report");
|
|
expect(calls.some((c) => c.method === "chat.postMessage")).toBe(false);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("--no-unfurl cannot be combined with file attachments", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "https://example.com",
|
|
options: { attach: ["./report.md"], unfurl: false },
|
|
}),
|
|
).rejects.toThrow(/--no-unfurl cannot be combined with --attach/);
|
|
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
test("sends initial comment only for the first attachment", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const first = join(dir, "report.md");
|
|
const second = join(dir, "log.txt");
|
|
await writeFile(first, "# report\n");
|
|
await writeFile(second, "ok\n");
|
|
|
|
const fetchMock = mock(async () => new Response("", { status: 200 }));
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "see files",
|
|
options: { attach: [first, second] },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
const completes = calls.filter((c) => c.method === "files.completeUploadExternal");
|
|
expect(completes).toHaveLength(2);
|
|
expect(completes[0]?.params.initial_comment).toBe("see files");
|
|
expect(completes[1]?.params.initial_comment).toBeUndefined();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("uploads attachment without text when text is empty", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const filePath = join(dir, "data.csv");
|
|
await writeFile(filePath, "a,b\n1,2\n");
|
|
|
|
const fetchMock = mock(async () => new Response("", { status: 200 }));
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "",
|
|
options: { attach: [filePath] },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls[0]?.method).toBe("files.getUploadURLExternal");
|
|
expect(calls[1]?.method).toBe("files.completeUploadExternal");
|
|
expect(calls[1]?.params.initial_comment).toBeUndefined();
|
|
expect(calls.some((c) => c.method === "chat.postMessage")).toBe(false);
|
|
});
|
|
|
|
test("--blocks: reads Block Kit JSON from file and passes through unchanged", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [
|
|
{ type: "header", text: { type: "plain_text", text: "Digest" } },
|
|
{
|
|
type: "table",
|
|
rows: [
|
|
[
|
|
{ type: "raw_text", text: "Name" },
|
|
{ type: "raw_text", text: "Why" },
|
|
],
|
|
[
|
|
{ type: "raw_text", text: "Caveman" },
|
|
{ type: "raw_text", text: "token cut" },
|
|
],
|
|
],
|
|
},
|
|
];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "fallback text",
|
|
options: { blocks: blocksPath },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.postMessage");
|
|
expect(calls[0]?.params).toEqual({
|
|
channel: "C12345678",
|
|
text: "fallback text",
|
|
thread_ts: undefined,
|
|
blocks,
|
|
});
|
|
});
|
|
|
|
test("--blocks: errors when JSON is not an array", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
await writeFile(blocksPath, JSON.stringify({ type: "header" }));
|
|
|
|
try {
|
|
expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "",
|
|
options: { blocks: blocksPath },
|
|
}),
|
|
).rejects.toThrow(/expected a JSON array/);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("--blocks: errors on malformed JSON", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
await writeFile(blocksPath, "{not valid json");
|
|
|
|
try {
|
|
expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "",
|
|
options: { blocks: blocksPath },
|
|
}),
|
|
).rejects.toThrow(/failed to parse JSON/);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("--blocks: overrides markdown-to-rich-text conversion when text is also provided", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [{ type: "divider" }];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "- bullet one\n- bullet two",
|
|
options: { blocks: blocksPath },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls[0]?.params.blocks).toEqual(blocks);
|
|
});
|
|
|
|
test("sends Slack manual links in lists as rich-text link blocks", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "- Review <https://example.com/pull/42|PR #42>",
|
|
options: {},
|
|
});
|
|
|
|
expect(calls[0]?.method).toBe("chat.postMessage");
|
|
expect(calls[0]?.params.blocks).toEqual([
|
|
{
|
|
type: "rich_text",
|
|
elements: [
|
|
{
|
|
type: "rich_text_list",
|
|
style: "bullet",
|
|
elements: [
|
|
{
|
|
type: "rich_text_section",
|
|
elements: [
|
|
{ type: "text", text: "Review " },
|
|
{ type: "link", url: "https://example.com/pull/42", text: "PR #42" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("--blocks: errors when an array element is not an object", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
await writeFile(blocksPath, JSON.stringify([{ type: "divider" }, "oops"]));
|
|
|
|
try {
|
|
await expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "",
|
|
options: { blocks: blocksPath },
|
|
}),
|
|
).rejects.toThrow(/element at index 1 is not a Block Kit block object/);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("--schedule sends through chat.scheduleMessage and returns scheduled metadata", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const when = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
const postAt = Math.floor(Date.parse(when) / 1000);
|
|
|
|
const result = await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "later",
|
|
options: { schedule: when },
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.scheduleMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "later",
|
|
post_at: postAt,
|
|
thread_ts: undefined,
|
|
},
|
|
},
|
|
]);
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
channel_id: "C12345678",
|
|
scheduled_message_id: "Q1234ABCD",
|
|
post_at: postAt,
|
|
thread_ts: undefined,
|
|
});
|
|
});
|
|
|
|
test("--schedule-in computes a relative post_at", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const before = Math.floor(Date.now() / 1000);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "later",
|
|
options: { scheduleIn: "3h" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.scheduleMessage");
|
|
expect(calls[0]?.params.post_at as number).toBeGreaterThanOrEqual(before + 3 * 3600 - 1);
|
|
expect(calls[0]?.params.post_at as number).toBeLessThanOrEqual(before + 3 * 3600 + 1);
|
|
});
|
|
|
|
test("--no-unfurl disables link and media previews for scheduled sends", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "https://example.com",
|
|
options: { scheduleIn: "3h", unfurl: false },
|
|
});
|
|
|
|
expect(calls[0]?.method).toBe("chat.scheduleMessage");
|
|
expect(calls[0]?.params).toMatchObject({
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
});
|
|
});
|
|
|
|
test("--schedule composes with blocks, thread replies, and reply broadcast", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-send-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [{ type: "section", text: { type: "mrkdwn", text: "summary" } }];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "fallback",
|
|
options: {
|
|
blocks: blocksPath,
|
|
threadTs: "1770160000.000001",
|
|
replyBroadcast: true,
|
|
scheduleIn: "30m",
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.scheduleMessage");
|
|
expect(calls[0]?.params).toMatchObject({
|
|
channel: "C12345678",
|
|
text: "fallback",
|
|
thread_ts: "1770160000.000001",
|
|
blocks,
|
|
reply_broadcast: true,
|
|
});
|
|
});
|
|
|
|
test("--schedule cannot be combined with file attachments", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await expect(
|
|
sendMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "later with file",
|
|
options: { attach: ["./report.md"], scheduleIn: "1h" },
|
|
}),
|
|
).rejects.toThrow(/cannot be combined with --attach/);
|
|
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("composeMessage", () => {
|
|
test("--no-unfurl disables link and media previews when CI sends immediately", async () => {
|
|
await withEnvironment({ CI: "1" }, async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await composeMessage({
|
|
ctx,
|
|
targetInput: "#general",
|
|
initialText: "https://example.com",
|
|
options: { unfurl: false },
|
|
});
|
|
|
|
expect(calls.at(-1)).toEqual({
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "https://example.com",
|
|
thread_ts: undefined,
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
test("keeps Slack's default preview behavior for a standard CI send", async () => {
|
|
await withEnvironment({ CI: "1" }, async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await composeMessage({
|
|
ctx,
|
|
targetInput: "#general",
|
|
initialText: "https://example.com",
|
|
options: {},
|
|
});
|
|
|
|
expect(calls.at(-1)).toEqual({
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "https://example.com",
|
|
thread_ts: undefined,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
test("--no-unfurl disables previews for a URL target when CI sends immediately", async () => {
|
|
await withEnvironment({ CI: "1" }, async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls, {
|
|
historyMessages: [{ ts: "1770160500.000100", text: "root" }],
|
|
});
|
|
|
|
await composeMessage({
|
|
ctx,
|
|
targetInput: "https://workspace.slack.com/archives/C12345678/p1770160500000100",
|
|
initialText: "https://example.com",
|
|
options: { unfurl: false },
|
|
});
|
|
|
|
expect(calls.at(-1)).toEqual({
|
|
method: "chat.postMessage",
|
|
params: {
|
|
channel: "C12345678",
|
|
text: "https://example.com",
|
|
thread_ts: "1770160500.000100",
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("scheduled message management", () => {
|
|
test("lists scheduled messages and forwards channel filters", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await listScheduledMessages({
|
|
ctx,
|
|
options: {
|
|
channel: "general",
|
|
cursor: "cursor-1",
|
|
oldest: "1770160000",
|
|
latest: "1770170000",
|
|
limit: "25",
|
|
},
|
|
});
|
|
|
|
const listCall = calls.find((c) => c.method === "chat.scheduledMessages.list");
|
|
expect(listCall?.params).toEqual({
|
|
channel: "C12345678",
|
|
cursor: "cursor-1",
|
|
oldest: "1770160000",
|
|
latest: "1770170000",
|
|
limit: 25,
|
|
});
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
scheduled_messages: [
|
|
{
|
|
id: "Q1234ABCD",
|
|
channel_id: "C12345678",
|
|
post_at: 1770168709,
|
|
text: "scheduled",
|
|
},
|
|
],
|
|
next_cursor: "next-1",
|
|
});
|
|
});
|
|
|
|
test("cancels scheduled messages with the required channel id", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await cancelScheduledMessage({
|
|
ctx,
|
|
scheduledMessageId: "Q1234ABCD",
|
|
options: { channel: "C12345678" },
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.deleteScheduledMessage",
|
|
params: { channel: "C12345678", scheduled_message_id: "Q1234ABCD" },
|
|
},
|
|
]);
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
channel_id: "C12345678",
|
|
scheduled_message_id: "Q1234ABCD",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("scheduled message time parsing", () => {
|
|
test("parses absolute ISO timestamps with explicit timezones", () => {
|
|
expect(parseAbsoluteSchedule("2026-06-15T18:00:00-07:00")).toBe(
|
|
Math.floor(Date.parse("2026-06-15T18:00:00-07:00") / 1000),
|
|
);
|
|
expect(() => parseAbsoluteSchedule("2026-06-15T18:00:00")).toThrow(/explicit timezone/);
|
|
});
|
|
|
|
test("parses named relative times like monday 9am", () => {
|
|
const now = new Date(2026, 4, 30, 12, 0, 0, 0);
|
|
const result = parseRelativeSchedule("monday 9am", { now });
|
|
const expected = new Date(now);
|
|
expected.setDate(now.getDate() + 2);
|
|
expected.setHours(9, 0, 0, 0);
|
|
expect(result).toBe(Math.floor(expected.getTime() / 1000));
|
|
});
|
|
|
|
test("rejects schedule times beyond Slack's 120 day limit", () => {
|
|
const now = new Date(2026, 4, 30, 12, 0, 0, 0);
|
|
const tooFar = new Date(now);
|
|
tooFar.setDate(now.getDate() + 121);
|
|
expect(() => resolveSchedulePostAt({ schedule: tooFar.toISOString() }, { now })).toThrow(
|
|
/120 days/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("editMessage", () => {
|
|
test("edits a normal text message without blocks", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "hello",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.update",
|
|
params: {
|
|
channel: "C12345678",
|
|
ts: "1770165109.628379",
|
|
text: "hello",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("edits literal angle bracket text without blocks", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "please use <fix>",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
method: "chat.update",
|
|
params: {
|
|
channel: "C12345678",
|
|
ts: "1770165109.628379",
|
|
text: "please use <fix>",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("edits a message URL with a link label without blocks", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
|
|
text: "Visit <https://example.com|Example>",
|
|
options: {},
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.channel).toBe("C12345678");
|
|
expect(calls[0]?.params.ts).toBe("1770165109.628379");
|
|
expect(calls[0]?.params.text).toBe("Visit <https://example.com|Example>");
|
|
expect(calls[0]?.params.blocks).toBeUndefined();
|
|
});
|
|
|
|
test("edits an inline mailto link without escaping it", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "Email <mailto:bob@example.com|Bob>",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.text).toBe("Email <mailto:bob@example.com|Bob>");
|
|
expect(calls[0]?.params.blocks).toBeUndefined();
|
|
});
|
|
|
|
test("edits an inline usergroup mention without escaping it", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "Ping <!subteam^S12345678|@team>",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.text).toBe("Ping <!subteam^S12345678|@team>");
|
|
expect(calls[0]?.params.blocks).toBeUndefined();
|
|
});
|
|
|
|
test("edits a channel target with inline formatting without blocks", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "Update *now*",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.text).toBe("Update *now*");
|
|
expect(calls[0]?.params.blocks).toBeUndefined();
|
|
});
|
|
|
|
test("edits a channel target with rich text blocks when text contains a list", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "- Post in <#C87654321|general>\n- Update *now*",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.blocks).toEqual([
|
|
{
|
|
type: "rich_text",
|
|
elements: [
|
|
{
|
|
type: "rich_text_list",
|
|
style: "bullet",
|
|
elements: [
|
|
{
|
|
type: "rich_text_section",
|
|
elements: [
|
|
{ type: "text", text: "Post in " },
|
|
{ type: "channel", channel_id: "C87654321" },
|
|
],
|
|
},
|
|
{
|
|
type: "rich_text_section",
|
|
elements: [
|
|
{ type: "text", text: "Update " },
|
|
{ type: "text", text: "now", style: { bold: true } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("editMessage", () => {
|
|
test("edits a normal message without blocks when no --blocks file is passed", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
|
|
const result = await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "hello",
|
|
options: { workspace: "https://workspace.slack.com", ts: "1770165109.628379" },
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params).toEqual({
|
|
channel: "C12345678",
|
|
ts: "1770165109.628379",
|
|
text: "hello",
|
|
});
|
|
});
|
|
|
|
test("edits a message URL with Block Kit JSON from file", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-edit-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [
|
|
{ type: "header", text: { type: "plain_text", text: "Edited" } },
|
|
{ type: "section", text: { type: "mrkdwn", text: "Updated body" } },
|
|
];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
|
|
text: "fallback text",
|
|
options: { blocks: blocksPath },
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params).toEqual({
|
|
channel: "C12345678",
|
|
ts: "1770165109.628379",
|
|
text: "fallback text",
|
|
blocks,
|
|
});
|
|
});
|
|
|
|
test("edits a channel target with --ts and Block Kit JSON from file", async () => {
|
|
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
|
const ctx = createContext(calls);
|
|
const dir = await mkdtemp(join(tmpdir(), "agent-slack-edit-test-"));
|
|
const blocksPath = join(dir, "blocks.json");
|
|
const blocks = [{ type: "divider" }];
|
|
await writeFile(blocksPath, JSON.stringify(blocks));
|
|
|
|
try {
|
|
await editMessage({
|
|
ctx,
|
|
targetInput: "C12345678",
|
|
text: "fallback text",
|
|
options: {
|
|
workspace: "https://workspace.slack.com",
|
|
ts: "1770165109.628379",
|
|
blocks: blocksPath,
|
|
},
|
|
});
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.method).toBe("chat.update");
|
|
expect(calls[0]?.params.blocks).toEqual(blocks);
|
|
});
|
|
});
|