Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ agent-slack message compose "#general"
# Open editor with initial text
agent-slack message compose "#general" "Here's my update"

# Open the editor and suppress link/media previews when sent
agent-slack message compose "#general" "Release notes: https://example.com" --no-unfurl

# Reply in a thread
agent-slack message compose "https://workspace.slack.com/archives/C123/p1700000000000000"
```
Expand Down Expand Up @@ -297,6 +300,7 @@ Send options for `message send`:
- `--attach <path>` upload a local file (repeatable; `<text>` is optional when attaching files)
- `--blocks <path>` send raw [Block Kit](https://docs.slack.dev/block-kit/) blocks from a JSON file (or `-` for stdin). Bypasses the automatic markdown-to-rich-text conversion, unlocking header/divider/section/table blocks and other structured layouts. Cannot be combined with `--attach`.
- `--reply-broadcast` when replying in a thread, also post the reply to the parent channel (Slack's "Also send to #channel" checkbox). For channel targets, pair with `--thread-ts`; for URL targets, the thread context is derived from the message. Not supported for DM targets; cannot be combined with `--attach`.
- `--no-unfurl` suppress Slack link and media previews. Also available on `message compose`; cannot be combined with `--attach`.
- `--schedule <time>` schedule delivery at an ISO 8601 timestamp with explicit timezone (for example `YYYY-MM-DDTHH:mm:ss-07:00`) or a Unix timestamp. The timestamp must be in the future and within Slack's 120-day scheduled-send limit. Works with `--blocks`, `--thread-ts`, and `--reply-broadcast`; cannot be combined with `--attach`.
- `--schedule-in <duration>` schedule delivery after a duration or simple future phrase (`30m`, `3h`, `2d`, `tomorrow 9am`, `monday 9am`; phrases use your local timezone). Mutually exclusive with `--schedule`; cannot be combined with `--attach`.

Expand Down
2 changes: 2 additions & 0 deletions skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ For scheduled writes, prefer `--schedule` with an ISO 8601 timestamp and explici

Named `later remind --in` values such as `tomorrow` or `monday` also use the executing environment's local timezone at 9:00. Confirm that timezone or pass an explicit Unix timestamp.

Use `--no-unfurl` with `message send` or `message compose` when the user wants Slack link and media previews suppressed. It cannot be combined with `message send --attach`.

Ordinary `message send` and `message edit` calls auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Inside auto-converted lists, use Slack's `<URL|label>` syntax because CommonMark `[label](URL)` links are not converted into labeled link elements.

Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd).
Expand Down
5 changes: 4 additions & 1 deletion src/cli/compose-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { parseMsgTarget } from "./targets.ts";
import { resolveChannelId, resolveChannelName, normalizeChannelInput } from "../slack/channels.ts";
import { warnOnTruncatedSlackUrl } from "./message-url-warning.ts";
import { openDraftEditor } from "./draft-server.ts";
import { buildUnfurlApiParams } from "./unfurl-options.ts";

export async function composeMessage(input: {
ctx: CliContext;
targetInput: string;
initialText?: string;
options: { workspace?: string; threadTs?: string };
options: { workspace?: string; threadTs?: string; unfurl?: boolean };
}): Promise<Record<string, unknown>> {
const target = parseMsgTarget(String(input.targetInput));
if (target.kind === "user") {
Expand Down Expand Up @@ -41,6 +42,7 @@ export async function composeMessage(input: {
channel: ref.channel_id,
text,
thread_ts: threadTs,
...buildUnfurlApiParams(input.options.unfurl),
});
return { ts: resp.ts as string };
},
Expand Down Expand Up @@ -76,6 +78,7 @@ export async function composeMessage(input: {
channel: channelId,
text,
thread_ts: input.options.threadTs,
...buildUnfurlApiParams(input.options.unfurl),
});
return { ts: resp.ts as string };
},
Expand Down
13 changes: 13 additions & 0 deletions src/cli/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { SlackApiClient } from "../slack/client.ts";
import { uploadLocalFileToSlack } from "../slack/upload.ts";
import { buildSlackMessageUrl } from "../slack/url.ts";
import { resolveSchedulePostAt } from "../slack/scheduled-messages.ts";
import { buildUnfurlApiParams } from "./unfurl-options.ts";

function loadBlocksFromPath(path: string): unknown[] {
const raw = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8");
Expand Down Expand Up @@ -114,6 +115,7 @@ export async function sendMessage(input: {
replyBroadcast?: boolean;
schedule?: string;
scheduleIn?: string;
unfurl?: boolean;
};
}): Promise<Record<string, unknown>> {
const target = parseMsgTarget(String(input.targetInput));
Expand All @@ -127,6 +129,11 @@ export async function sendMessage(input: {
"--schedule/--schedule-in cannot be combined with --attach (Slack scheduled messages do not support file uploads).",
);
}
if (input.options.unfurl === false && attachPaths.length > 0) {
throw new Error(
"--no-unfurl cannot be combined with --attach (Slack file uploads do not accept unfurl parameters).",
);
}
const formattedText = formatOutboundSlackText(input.text);
const blocks = input.options.blocks
? loadBlocksFromPath(input.options.blocks)
Expand All @@ -153,6 +160,7 @@ export async function sendMessage(input: {
replyBroadcast: input.options.replyBroadcast,
attachPaths,
postAt,
unfurl: input.options.unfurl,
});
},
});
Expand All @@ -176,6 +184,7 @@ export async function sendMessage(input: {
blocks,
attachPaths,
postAt,
unfurl: input.options.unfurl,
});
},
});
Expand Down Expand Up @@ -204,6 +213,7 @@ export async function sendMessage(input: {
replyBroadcast: input.options.replyBroadcast,
attachPaths,
postAt,
unfurl: input.options.unfurl,
});
},
});
Expand Down Expand Up @@ -232,6 +242,7 @@ async function sendMessageToChannel(input: {
replyBroadcast?: boolean;
attachPaths: string[];
postAt?: number;
unfurl?: boolean;
}): Promise<Record<string, unknown>> {
if (input.postAt !== undefined) {
const resp = await input.client.api("chat.scheduleMessage", {
Expand All @@ -241,6 +252,7 @@ async function sendMessageToChannel(input: {
thread_ts: input.threadTs,
...(input.blocks ? { blocks: input.blocks } : {}),
...(input.replyBroadcast && input.threadTs ? { reply_broadcast: true } : {}),
...buildUnfurlApiParams(input.unfurl),
});
const channelId = typeof resp.channel === "string" ? resp.channel : input.channelId;
const scheduledMessageId =
Expand All @@ -261,6 +273,7 @@ async function sendMessageToChannel(input: {
thread_ts: input.threadTs,
...(input.blocks ? { blocks: input.blocks } : {}),
...(input.replyBroadcast && input.threadTs ? { reply_broadcast: true } : {}),
...buildUnfurlApiParams(input.unfurl),
});
const ts = typeof resp.ts === "string" ? resp.ts : undefined;
const channelId = typeof resp.channel === "string" ? resp.channel : input.channelId;
Expand Down
5 changes: 4 additions & 1 deletion src/cli/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
"--schedule-in <duration>",
"Schedule delivery within 120 days after a duration or future phrase, e.g. 3h or monday 9am. Named phrases use this process's local timezone. Cannot be combined with --attach.",
)
.option("--no-unfurl", "Suppress link and media previews. Cannot be combined with --attach.")
.action(async (...args) => {
const [targetInput, text, options] = args as [
string,
Expand All @@ -215,6 +216,7 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
replyBroadcast?: boolean;
schedule?: string;
scheduleIn?: string;
unfurl?: boolean;
},
];
const hasAttach = (options.attach ?? []).length > 0;
Expand Down Expand Up @@ -288,11 +290,12 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
"--thread-ts <ts>",
"Thread root ts to post into; overrides the URL-derived thread when supplied",
)
.option("--no-unfurl", "Suppress link and media previews")
.action(async (...args) => {
const [targetInput, text, options] = args as [
string,
string | undefined,
{ workspace?: string; threadTs?: string },
{ workspace?: string; threadTs?: string; unfurl?: boolean },
];
try {
const payload = await composeMessage({
Expand Down
7 changes: 6 additions & 1 deletion src/cli/safe-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type SendOptionsForRedirect = {
replyBroadcast?: boolean;
schedule?: string;
scheduleIn?: string;
unfurl?: boolean;
};

/**
Expand Down Expand Up @@ -87,7 +88,11 @@ export async function redirectSendToDraft(
ctx: input.ctx,
targetInput: input.targetInput,
initialText: input.text,
options: { workspace: input.options.workspace, threadTs: input.options.threadTs },
options: {
workspace: input.options.workspace,
threadTs: input.options.threadTs,
unfurl: input.options.unfurl,
},
});
return { safe_mode: true, redirected_from: "send", ...payload };
}
6 changes: 6 additions & 0 deletions src/cli/unfurl-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function buildUnfurlApiParams(unfurl: boolean | undefined): {
unfurl_links?: false;
unfurl_media?: false;
} {
return unfurl === false ? { unfurl_links: false, unfurl_media: false } : {};
}
2 changes: 2 additions & 0 deletions test/help-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("agent-facing help contracts", () => {
expect(optionDescription(send, "--schedule-in")).toContain("local timezone");
expect(optionDescription(send, "--thread-ts")).toContain("channel targets");
expect(optionDescription(send, "--reply-broadcast")).toContain("DM targets");
expect(optionDescription(send, "--no-unfurl")).toContain("link and media previews");
});

test("message compose identifies its CI send behavior", () => {
Expand All @@ -55,6 +56,7 @@ describe("agent-facing help contracts", () => {
expect(compose.description()).toContain("CI skips the editor");
expect(compose.registeredArguments[1]?.description).toContain("sent immediately");
expect(optionDescription(compose, "--thread-ts")).toContain("overrides the URL-derived thread");
expect(optionDescription(compose, "--no-unfurl")).toContain("link and media previews");
});

test("Slack-native drafts document DM targeting and inherited-broadcast controls", () => {
Expand Down
27 changes: 27 additions & 0 deletions test/helpers/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export async function withEnvironment<T>(
overrides: Record<string, string | undefined>,
work: () => T | Promise<T>,
): Promise<T> {
const originalValues = new Map(
Object.keys(overrides).map((name) => [name, process.env[name]] as const),
);

try {
for (const [name, value] of Object.entries(overrides)) {
setEnvironmentVariable(name, value);
}
return await work();
} finally {
for (const [name, value] of originalValues) {
setEnvironmentVariable(name, value);
}
}
}

function setEnvironmentVariable(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
Loading