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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ agent-slack
Notes:

- Slack data commands output aggressively pruned JSON (`null`/empty fields removed); help, update, and some authentication setup commands output text.
- Attached files are auto-downloaded and returned as absolute local paths.
- Attached files are auto-downloaded by default and returned as absolute local paths.

## Authentication (no fancy setup)

Expand Down Expand Up @@ -196,6 +196,9 @@ agent-slack message list "https://workspace.slack.com/archives/C123/p17000000000
# Recent channel messages (browse channel history)
agent-slack message list "#general" --limit 20

# Metadata-only scan (keeps file metadata without downloading attachment bodies)
agent-slack message list "#general" --limit 20 --no-download

# Recent channel messages that are marked with :eyes:
agent-slack message list "#general" --with-reaction eyes --oldest "1770165109.000000" --limit 20

Expand Down Expand Up @@ -444,7 +447,7 @@ When to use which:

### Files (snippets/images/attachments)

`message get/list` auto-download attached files to an agent-friendly temp directory and return file metadata in `message.files[]`, including `name` when Slack provides the original filename and `path` for the local download. Failed downloads keep the attachment entry, preserve `message.files[].path` with a local `.download-error.txt` file, and include `message.files[].error`. `search messages` and `search all` use the same attachment shape for message results, while `search files` skips entries whose download fails. Use `search messages --content-type file` when you also need the source-message permalink for a reply.
`message get/list` auto-download attached files by default to an agent-friendly temp directory and return file metadata in `message.files[]`, including `name` when Slack provides the original filename and `path` for the local download. For metadata-only channel or full-thread scans, `message list --no-download` skips attachment body downloads while preserving available `name`, `mimetype`, and `mode` metadata without `path` or `error`. Failed downloads keep the attachment entry, preserve `message.files[].path` with a local `.download-error.txt` file, and include `message.files[].error`. `search messages` and `search all` use the same attachment shape for message results, while `search files` skips entries whose download fails. Use `search messages --content-type file` when you also need the source-message permalink for a reply.

- macOS default: `~/.agent-slack/tmp/downloads/`

Expand Down
2 changes: 1 addition & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ If a capability named here is absent from installed help, report version skew in
1. Run `agent-slack auth whoami`. If needed, import credentials with `auth import-desktop`, `auth import-brave`, `auth import-chrome`, or `auth import-firefox`, then run `auth test`.
2. Prefer a Slack message URL when one is available. It carries the workspace, channel, and timestamp needed by most message operations.
3. Choose the narrowest read operation: `message get` for one message, `message list` for a full thread or channel history, and `search messages` or `search files` for discovery.
4. Use output limits such as `--limit`, `--max-body-chars`, and `--max-content-chars` to avoid unnecessary context.
4. Use output limits such as `--limit`, `--max-body-chars`, and `--max-content-chars` to avoid unnecessary context. For metadata-only channel or thread scans, use `message list --no-download`; file metadata remains available without local paths.
5. For a requested write, execute only the requested mutation and verify the resulting JSON metadata.

For scheduled writes, prefer `--schedule` with an ISO 8601 timestamp and explicit offset when timezone matters. Named `--schedule-in` phrases use the executing environment's local timezone; confirm that it matches the user's intent.
Expand Down
3 changes: 2 additions & 1 deletion skills/agent-slack/references/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ Use `--max-body-chars`, `--max-content-chars`, `--limit`, or a command's counts-

## Downloaded files

Message reads and searches download Slack files locally. Each successful file includes an absolute `path` plus available metadata such as `name`, `mimetype`, and `mode`.
Message reads and searches download Slack files locally by default. Each successful file includes an absolute `path` plus available metadata such as `name`, `mimetype`, and `mode`.

- Successful downloads are returned as absolute paths in output.
- `message list --no-download` never downloads attachment bodies. Channel-history and full-thread results retain available file `name`, `mimetype`, and `mode` metadata without `path` or `error`.
- `message get` preserves failed downloads in `message.files[]`; `message list` uses `messages[].files[]`. Each failed entry has `error` and a `path` to a local `.download-error.txt` file.
- Message results from `search messages|all` preserve failed attachment downloads with `messages[].files[].error` and keep `messages[].files[].path` pointing to a local `.download-error.txt` file.
- `search files` warns and skips files whose download fails. Do not treat a skip warning as proof that no matching file exists; retry through the source message with `message get/list` when possible.
Expand Down
1 change: 1 addition & 0 deletions src/cli/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type MessageCommandOptions = {
workspace?: string;
ts?: string;
threadTs?: string;
download?: boolean;
limit?: string;
oldest?: string;
latest?: string;
Expand Down
4 changes: 4 additions & 0 deletions src/cli/message-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex
)
.option("--ts <ts>", "Message ts (resolve message to its thread)")
.option("--limit <n>", "Max messages to return for channel history (default 25, max 200)")
.option(
"--no-download",
"Do not download attached file bodies; retain file metadata without local paths",
)
.option("--oldest <ts>", "Only messages after this ts (channel history mode)")
.option("--latest <ts>", "Only messages before this ts (channel history mode)")
.option(
Expand Down
4 changes: 4 additions & 0 deletions src/cli/message-file-downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ async function downloadCanvasAsMarkdown(input: {
export async function downloadMessageFiles(input: {
auth: SlackAuth;
messages: SlackMessageSummary[];
download?: boolean;
}): Promise<Record<string, DownloadResult>> {
const downloadedPaths: Record<string, DownloadResult> = {};
if (input.download === false) {
return downloadedPaths;
}
const downloadsDir = await ensureDownloadsDir();

for (const message of input.messages) {
Expand Down
59 changes: 51 additions & 8 deletions src/cli/message-read-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export async function handleMessageList(input: {
);
}
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
const download = input.options.download !== false;

return input.ctx.withAutoRefresh({
workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
Expand All @@ -153,14 +154,23 @@ export async function handleMessageList(input: {
warnOnTruncatedSlackUrl(ref);
const { client, auth } = await input.ctx.getClientForWorkspace(ref.workspace_url);
const includeReactions = Boolean(input.options.includeReactions);
const msg = await fetchMessage(client, { ref, includeReactions });
const msg = await fetchMessage(client, {
ref,
includeReactions,
fetchFileInfo: download,
});
const rootTs = msg.thread_ts ?? msg.ts;
const threadMessages = await fetchThread(client, {
channelId: ref.channel_id,
threadTs: rootTs,
includeReactions,
fetchFileInfo: download,
});
const downloadedPaths = await downloadMessageFiles({
auth,
messages: threadMessages,
download,
});
const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages });
const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
const referencedUserIds = collectReferencedUserIds(threadMessages, {
includeReactions,
Expand All @@ -176,7 +186,14 @@ export async function handleMessageList(input: {
: new Map();
return pruneEmpty({
messages: threadMessages
.map((m) => toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths }))
.map((m) =>
toCompactMessage(m, {
maxBodyChars,
includeReactions,
downloadedPaths,
includeUndownloadedFileMetadata: !download,
}),
)
.map(toThreadListMessage),
referenced_users: toReferencedUsers(referencedUserIds, usersById),
}) as Record<string, unknown>;
Expand Down Expand Up @@ -211,8 +228,13 @@ export async function handleMessageList(input: {
includeReactions: includeReactions || hasReactionFilters,
withReactions,
withoutReactions,
fetchFileInfo: download,
});
const downloadedPaths = await downloadMessageFiles({
auth,
messages: channelMessages,
download,
});
const downloadedPaths = await downloadMessageFiles({ auth, messages: channelMessages });
const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
const referencedUserIds = collectReferencedUserIds(channelMessages, {
includeReactions,
Expand All @@ -229,7 +251,12 @@ export async function handleMessageList(input: {
return pruneEmpty({
channel_id: channelId,
messages: channelMessages.map((m) =>
toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths }),
toCompactMessage(m, {
maxBodyChars,
includeReactions,
downloadedPaths,
includeUndownloadedFileMetadata: !download,
}),
),
referenced_users: toReferencedUsers(referencedUserIds, usersById),
}) as Record<string, unknown>;
Expand All @@ -251,7 +278,11 @@ export async function handleMessageList(input: {
raw: input.targetInput,
};
const includeReactions = Boolean(input.options.includeReactions);
const msg = await fetchMessage(client, { ref, includeReactions });
const msg = await fetchMessage(client, {
ref,
includeReactions,
fetchFileInfo: download,
});
return msg.thread_ts ?? msg.ts;
})());

Expand All @@ -260,8 +291,13 @@ export async function handleMessageList(input: {
channelId,
threadTs: rootTs,
includeReactions,
fetchFileInfo: download,
});
const downloadedPaths = await downloadMessageFiles({
auth,
messages: threadMessages,
download,
});
const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages });
const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
const referencedUserIds = collectReferencedUserIds(threadMessages, {
includeReactions,
Expand All @@ -277,7 +313,14 @@ export async function handleMessageList(input: {
: new Map();
return pruneEmpty({
messages: threadMessages
.map((m) => toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths }))
.map((m) =>
toCompactMessage(m, {
maxBodyChars,
includeReactions,
downloadedPaths,
includeUndownloadedFileMetadata: !download,
}),
)
.map(toThreadListMessage),
referenced_users: toReferencedUsers(referencedUserIds, usersById),
}) as Record<string, unknown>;
Expand Down
5 changes: 4 additions & 1 deletion src/slack/message-compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function toCompactMessage(
maxBodyChars?: number;
includeReactions?: boolean;
downloadedPaths?: Record<string, DownloadResult>;
includeUndownloadedFileMetadata?: boolean;
},
): CompactSlackMessage {
const maxBodyChars = input?.maxBodyChars ?? 8000;
Expand All @@ -52,7 +53,9 @@ export function toCompactMessage(
?.map((f) => {
const entry = input?.downloadedPaths?.[f.id];
if (!entry) {
return null;
return input?.includeUndownloadedFileMetadata
? { name: f.name, mimetype: f.mimetype, mode: f.mode }
: null;
}
return entry.ok
? { name: f.name, mimetype: f.mimetype, mode: f.mode, path: entry.path }
Expand Down
31 changes: 26 additions & 5 deletions src/slack/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type SlackMessageSummary = {

export async function fetchMessage(
client: SlackApiClient,
input: { ref: SlackMessageRef; includeReactions?: boolean },
input: { ref: SlackMessageRef; includeReactions?: boolean; fetchFileInfo?: boolean },
): Promise<SlackMessageSummary> {
const history = await client.api("conversations.history", {
channel: input.ref.channel_id,
Expand Down Expand Up @@ -89,7 +89,12 @@ export async function fetchMessage(
const files = asArray(msg.files)
.map((f) => toSlackFileSummary(f))
.filter((f): f is SlackFileSummary => f !== null);
const enrichedFiles = files.length > 0 ? await enrichFiles(client, files) : undefined;
const enrichedFiles =
files.length > 0
? input.fetchFileInfo === false
? files
: await enrichFiles(client, files)
: undefined;

const text = getString(msg.text) ?? "";
const ts = getString(msg.ts) ?? input.ref.message_ts;
Expand Down Expand Up @@ -157,6 +162,7 @@ export async function fetchChannelHistory(
includeReactions?: boolean;
withReactions?: string[];
withoutReactions?: string[];
fetchFileInfo?: boolean;
},
): Promise<SlackMessageSummary[]> {
const raw = input.limit ?? 25;
Expand Down Expand Up @@ -196,7 +202,12 @@ export async function fetchChannelHistory(
const files = asArray(m.files)
.map((f) => toSlackFileSummary(f))
.filter((f): f is SlackFileSummary => f !== null);
const enrichedFiles = files.length > 0 ? await enrichFiles(client, files) : undefined;
const enrichedFiles =
files.length > 0
? input.fetchFileInfo === false
? files
: await enrichFiles(client, files)
: undefined;

const text = getString(m.text) ?? "";
out.push({
Expand Down Expand Up @@ -266,7 +277,12 @@ export function passesReactionNameFilters(

export async function fetchThread(
client: SlackApiClient,
input: { channelId: string; threadTs: string; includeReactions?: boolean },
input: {
channelId: string;
threadTs: string;
includeReactions?: boolean;
fetchFileInfo?: boolean;
},
): Promise<SlackMessageSummary[]> {
const out: SlackMessageSummary[] = [];
let cursor: string | undefined;
Expand All @@ -287,7 +303,12 @@ export async function fetchThread(
const files = asArray(m.files)
.map((f) => toSlackFileSummary(f))
.filter((f): f is SlackFileSummary => f !== null);
const enrichedFiles = files.length > 0 ? await enrichFiles(client, files) : undefined;
const enrichedFiles =
files.length > 0
? input.fetchFileInfo === false
? files
: await enrichFiles(client, files)
: undefined;

const text = getString(m.text) ?? "";
out.push({
Expand Down
27 changes: 27 additions & 0 deletions test/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ describe("downloadMessageFiles", () => {
await rm(tempDir, { recursive: true, force: true });
});

test("does not fetch file bodies when downloads are disabled", async () => {
const fetchMock = mockFetchReject(new Error("attachment body should not be fetched"));

const result = await downloadMessageFiles({
auth: AUTH,
download: false,
messages: [
{
channel_id: "C123",
ts: "1234567890.000001",
text: "hello",
markdown: "hello",
files: [
{
id: "F0",
name: "oversized.bin",
url_private_download: "https://files.slack.com/files/oversized.bin",
},
],
},
],
});

expect(result).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
});

test("records canvas auth failures as structured errors", async () => {
mockFetchOk(
"<html><head><title>Sign in</title></head><body>Sign in</body></html>",
Expand Down
10 changes: 10 additions & 0 deletions test/help-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ function buildProgram(): Command {
}

describe("agent-facing help contracts", () => {
test("message list documents metadata-only attachment scans", () => {
const list = findCommand(buildProgram(), "message", "list");

expect(optionDescription(list, "--no-download")).toContain("file metadata");
expect(optionDescription(list, "--no-download")).toContain("without local paths");
expect(list.getOptionValue("download")).toBe(true);
list.parseOptions(["--no-download"]);
expect(list.getOptionValue("download")).toBe(false);
});

test("message send documents non-obvious formatting and scheduling behavior", () => {
const send = findCommand(buildProgram(), "message", "send");

Expand Down
25 changes: 25 additions & 0 deletions test/message-compact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ describe("toCompactMessage", () => {
expect(compact.files).toBeUndefined();
});

test("includes file metadata without a path when explicitly requested", () => {
const msg = makeMessage([
{
id: "F1",
name: "diagram.png",
mimetype: "image/png",
mode: "hosted",
url_private: "https://example.com/f1",
},
]);
const compact = toCompactMessage(msg, {
downloadedPaths: {},
includeUndownloadedFileMetadata: true,
});
expect(compact.files).toEqual([
{
name: "diagram.png",
mimetype: "image/png",
mode: "hosted",
},
]);
expect(compact.files![0]).not.toHaveProperty("path");
expect(compact.files![0]).not.toHaveProperty("error");
});

test("mixes successful and failed downloads", () => {
const msg = makeMessage([
{
Expand Down
Loading
Loading