From a7028515d4864d302579021cb4347d8ef1c1c866 Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Sat, 25 Jul 2026 13:09:57 -0700 Subject: [PATCH 1/2] feat(message): list files without downloading bodies --- README.md | 7 +- skills/agent-slack/SKILL.md | 2 +- skills/agent-slack/references/output.md | 3 +- src/cli/message-actions.ts | 1 + src/cli/message-command.ts | 4 + src/cli/message-file-downloads.ts | 4 + src/cli/message-read-actions.ts | 44 +++- src/slack/message-compact.ts | 5 +- test/files.test.ts | 27 +++ test/help-contracts.test.ts | 10 + test/message-compact.test.ts | 25 ++ test/message-read-actions.test.ts | 307 ++++++++++++++++++++++++ 12 files changed, 428 insertions(+), 11 deletions(-) create mode 100644 test/message-read-actions.test.ts diff --git a/README.md b/README.md index 7fb9c07..b55dac9 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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/` diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index c1143e1..c57481f 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -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. diff --git a/skills/agent-slack/references/output.md b/skills/agent-slack/references/output.md index 1234926..8e3c83e 100644 --- a/skills/agent-slack/references/output.md +++ b/skills/agent-slack/references/output.md @@ -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. diff --git a/src/cli/message-actions.ts b/src/cli/message-actions.ts index 7de5045..e323c64 100644 --- a/src/cli/message-actions.ts +++ b/src/cli/message-actions.ts @@ -43,6 +43,7 @@ export type MessageCommandOptions = { workspace?: string; ts?: string; threadTs?: string; + download?: boolean; limit?: string; oldest?: string; latest?: string; diff --git a/src/cli/message-command.ts b/src/cli/message-command.ts index b790e60..3093e12 100644 --- a/src/cli/message-command.ts +++ b/src/cli/message-command.ts @@ -68,6 +68,10 @@ export function registerMessageCommand(input: { program: Command; ctx: CliContex ) .option("--ts ", "Message ts (resolve message to its thread)") .option("--limit ", "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 ", "Only messages after this ts (channel history mode)") .option("--latest ", "Only messages before this ts (channel history mode)") .option( diff --git a/src/cli/message-file-downloads.ts b/src/cli/message-file-downloads.ts index d7561be..7c8f0d7 100644 --- a/src/cli/message-file-downloads.ts +++ b/src/cli/message-file-downloads.ts @@ -85,8 +85,12 @@ async function downloadCanvasAsMarkdown(input: { export async function downloadMessageFiles(input: { auth: SlackAuth; messages: SlackMessageSummary[]; + download?: boolean; }): Promise> { const downloadedPaths: Record = {}; + if (input.download === false) { + return downloadedPaths; + } const downloadsDir = await ensureDownloadsDir(); for (const message of input.messages) { diff --git a/src/cli/message-read-actions.ts b/src/cli/message-read-actions.ts index 5c1f676..8249d0c 100644 --- a/src/cli/message-read-actions.ts +++ b/src/cli/message-read-actions.ts @@ -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, @@ -160,7 +161,11 @@ export async function handleMessageList(input: { threadTs: rootTs, includeReactions, }); - const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages }); + const downloadedPaths = await downloadMessageFiles({ + auth, + messages: threadMessages, + download, + }); const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10); const referencedUserIds = collectReferencedUserIds(threadMessages, { includeReactions, @@ -176,7 +181,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; @@ -212,7 +224,11 @@ export async function handleMessageList(input: { withReactions, withoutReactions, }); - const downloadedPaths = await downloadMessageFiles({ auth, messages: channelMessages }); + const downloadedPaths = await downloadMessageFiles({ + auth, + messages: channelMessages, + download, + }); const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10); const referencedUserIds = collectReferencedUserIds(channelMessages, { includeReactions, @@ -229,7 +245,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; @@ -261,7 +282,11 @@ export async function handleMessageList(input: { threadTs: rootTs, includeReactions, }); - const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages }); + const downloadedPaths = await downloadMessageFiles({ + auth, + messages: threadMessages, + download, + }); const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10); const referencedUserIds = collectReferencedUserIds(threadMessages, { includeReactions, @@ -277,7 +302,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; diff --git a/src/slack/message-compact.ts b/src/slack/message-compact.ts index dd9919c..c5a39c4 100644 --- a/src/slack/message-compact.ts +++ b/src/slack/message-compact.ts @@ -37,6 +37,7 @@ export function toCompactMessage( maxBodyChars?: number; includeReactions?: boolean; downloadedPaths?: Record; + includeUndownloadedFileMetadata?: boolean; }, ): CompactSlackMessage { const maxBodyChars = input?.maxBodyChars ?? 8000; @@ -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 } diff --git a/test/files.test.ts b/test/files.test.ts index ad58739..0e3f0f4 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -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( "Sign inSign in", diff --git a/test/help-contracts.test.ts b/test/help-contracts.test.ts index db25d4c..fc93773 100644 --- a/test/help-contracts.test.ts +++ b/test/help-contracts.test.ts @@ -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"); diff --git a/test/message-compact.test.ts b/test/message-compact.test.ts index 234fe29..eeb107f 100644 --- a/test/message-compact.test.ts +++ b/test/message-compact.test.ts @@ -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([ { diff --git a/test/message-read-actions.test.ts b/test/message-read-actions.test.ts new file mode 100644 index 0000000..7906629 --- /dev/null +++ b/test/message-read-actions.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CliContext } from "../src/cli/context.ts"; +import type { MessageCommandOptions } from "../src/cli/message-actions.ts"; +import { handleMessageList } from "../src/cli/message-read-actions.ts"; + +const ROOT_TS = "1700000000.000001"; +const REPLY_TS = "1700000001.000002"; +const originalFetch = globalThis.fetch; +const originalXdgRuntimeDir = process.env.XDG_RUNTIME_DIR; + +function createContext(responses: Record>): { + ctx: CliContext; + calls: string[]; +} { + const calls: string[] = []; + const client = { + api: async (method: string): Promise> => { + calls.push(method); + const response = responses[method]; + if (!response) { + throw new Error(`Unexpected API method: ${method}`); + } + return response; + }, + }; + return { + calls, + ctx: { + effectiveWorkspaceUrl: (workspace?: string) => workspace, + assertWorkspaceSpecifiedForChannelNames: async () => {}, + withAutoRefresh: async (input: { work: () => Promise }) => input.work(), + getClientForWorkspace: async () => ({ + client: client as never, + auth: { auth_type: "standard", token: "xoxb-test" }, + workspace_url: "https://workspace.slack.com", + }), + } as unknown as CliContext, + }; +} + +function hostedFile(id: string, metadata: { name: string; mimetype: string }) { + return { + id, + ...metadata, + mode: "hosted", + url_private_download: `https://files.slack.com/files/${metadata.name}`, + }; +} + +function setFetchMock(fn: (...args: unknown[]) => Promise) { + const mocked = mock(fn) as unknown as typeof globalThis.fetch; + mocked.preconnect = () => {}; + globalThis.fetch = mocked; + return mocked; +} + +function rejectAttachmentFetch() { + return setFetchMock(() => Promise.reject(new Error("attachment body should not be fetched"))); +} + +async function listMessages( + ctx: CliContext, + input: Omit & { targetInput: string }, +) { + const { targetInput, ...options } = input; + return handleMessageList({ + ctx, + targetInput, + options: { maxBodyChars: "8000", ...options }, + }); +} + +function firstFile(result: Record, messageIndex = 0): Record { + return (result.messages as { files?: Record[] }[])[messageIndex]!.files![0]!; +} + +function expectMetadataOnly( + file: Record, + expected: { name: string; mimetype: string }, +) { + expect(file).toEqual({ ...expected, mode: "hosted" }); + expect(file).not.toHaveProperty("path"); + expect(file).not.toHaveProperty("error"); +} + +describe("message list attachment downloads", () => { + let runtimeDir = ""; + + beforeEach(async () => { + runtimeDir = await mkdtemp(join(tmpdir(), "agent-slack-message-list-test-")); + process.env.XDG_RUNTIME_DIR = runtimeDir; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + process.env.XDG_RUNTIME_DIR = originalXdgRuntimeDir; + await rm(runtimeDir, { recursive: true, force: true }); + }); + + test("channel history keeps file metadata without fetching bodies", async () => { + const { ctx, calls } = createContext({ + "conversations.history": { + messages: [ + { + ts: ROOT_TS, + text: "metadata only", + user: "U11111111", + files: [ + hostedFile("FCHANNEL", { + name: "oversized.bin", + mimetype: "application/octet-stream", + }), + ], + }, + ], + }, + }); + const fetchMock = rejectAttachmentFetch(); + + const result = await listMessages(ctx, { + targetInput: "C12345678", + download: false, + }); + + expect(calls).toEqual(["conversations.history"]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + channel_id: "C12345678", + messages: [ + { + channel_id: "C12345678", + ts: ROOT_TS, + author: { user_id: "U11111111" }, + content: "metadata only", + }, + ], + }); + expectMetadataOnly(firstFile(result), { + name: "oversized.bin", + mimetype: "application/octet-stream", + }); + }); + + test("URL thread listing keeps file metadata without fetching bodies", async () => { + const { ctx, calls } = createContext({ + "conversations.history": { + messages: [{ ts: ROOT_TS, text: "thread root", user: "U11111111" }], + }, + "conversations.replies": { + messages: [ + { ts: ROOT_TS, text: "thread root", user: "U11111111" }, + { + ts: REPLY_TS, + thread_ts: ROOT_TS, + text: "reply with a file", + user: "U22222222", + files: [ + hostedFile("FTHREAD", { + name: "thread-report.pdf", + mimetype: "application/pdf", + }), + ], + }, + ], + }, + }); + const fetchMock = rejectAttachmentFetch(); + + const result = await listMessages(ctx, { + targetInput: "https://workspace.slack.com/archives/C12345678/p1700000000000001", + download: false, + }); + + expect(calls).toEqual(["conversations.history", "conversations.replies"]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + messages: [ + { ts: ROOT_TS, content: "thread root", author: { user_id: "U11111111" } }, + { ts: REPLY_TS, content: "reply with a file", author: { user_id: "U22222222" } }, + ], + }); + expectMetadataOnly(firstFile(result, 1), { + name: "thread-report.pdf", + mimetype: "application/pdf", + }); + }); + + test("channel thread listing keeps file metadata without fetching bodies", async () => { + const { ctx } = createContext({ + "conversations.replies": { + messages: [ + { + ts: ROOT_TS, + text: "metadata-only channel thread", + files: [ + hostedFile("FCHANNELTHREAD", { + name: "thread-archive.zip", + mimetype: "application/zip", + }), + ], + }, + ], + }, + }); + const fetchMock = rejectAttachmentFetch(); + + const result = await listMessages(ctx, { + targetInput: "C12345678", + threadTs: ROOT_TS, + download: false, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + messages: [{ ts: ROOT_TS, content: "metadata-only channel thread" }], + }); + expectMetadataOnly(firstFile(result), { + name: "thread-archive.zip", + mimetype: "application/zip", + }); + }); + + test("channel --ts thread resolution does not fetch file bodies", async () => { + const { ctx, calls } = createContext({ + "conversations.history": { + messages: [{ ts: REPLY_TS, thread_ts: ROOT_TS, text: "selected reply" }], + }, + "conversations.replies": { + messages: [ + { + ts: ROOT_TS, + text: "resolved thread root", + files: [ + hostedFile("FRESOLVED", { + name: "resolved.csv", + mimetype: "text/csv", + }), + ], + }, + { ts: REPLY_TS, thread_ts: ROOT_TS, text: "selected reply" }, + ], + }, + }); + const fetchMock = rejectAttachmentFetch(); + + const result = await listMessages(ctx, { + targetInput: "C12345678", + ts: REPLY_TS, + download: false, + }); + + expect(calls).toEqual(["conversations.history", "conversations.replies"]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + messages: [ + { ts: ROOT_TS, content: "resolved thread root" }, + { ts: REPLY_TS, content: "selected reply" }, + ], + }); + expectMetadataOnly(firstFile(result), { + name: "resolved.csv", + mimetype: "text/csv", + }); + }); + + test("channel thread listing downloads file bodies by default", async () => { + const { ctx } = createContext({ + "conversations.replies": { + messages: [ + { + ts: ROOT_TS, + text: "download this file", + files: [ + hostedFile("FDOWNLOAD", { + name: "report.txt", + mimetype: "text/plain", + }), + ], + }, + ], + }, + }); + const fetchMock = setFetchMock(() => + Promise.resolve( + new Response("attachment bytes", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ), + ); + + const result = await listMessages(ctx, { + targetInput: "C12345678", + threadTs: ROOT_TS, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const file = firstFile(result); + expect(file.name).toBe("report.txt"); + expect(file.error).toBeUndefined(); + expect(file.path).toBe(join(runtimeDir, "agent-slack", "tmp", "downloads", "FDOWNLOAD.txt")); + expect(await readFile(String(file.path), "utf8")).toBe("attachment bytes"); + }); +}); From 2555f8cf0648650c7112073dab62f07062dfd72d Mon Sep 17 00:00:00 2001 From: Darwin Wu Date: Fri, 11 Sep 2026 16:38:23 -0700 Subject: [PATCH 2/2] fix(message): skip snippet hydration without downloads --- src/cli/message-read-actions.ts | 15 +++++++- src/slack/messages.ts | 31 ++++++++++++--- test/message-read-actions.test.ts | 63 +++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/src/cli/message-read-actions.ts b/src/cli/message-read-actions.ts index 8249d0c..d2d000f 100644 --- a/src/cli/message-read-actions.ts +++ b/src/cli/message-read-actions.ts @@ -154,12 +154,17 @@ 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, @@ -223,6 +228,7 @@ export async function handleMessageList(input: { includeReactions: includeReactions || hasReactionFilters, withReactions, withoutReactions, + fetchFileInfo: download, }); const downloadedPaths = await downloadMessageFiles({ auth, @@ -272,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; })()); @@ -281,6 +291,7 @@ export async function handleMessageList(input: { channelId, threadTs: rootTs, includeReactions, + fetchFileInfo: download, }); const downloadedPaths = await downloadMessageFiles({ auth, diff --git a/src/slack/messages.ts b/src/slack/messages.ts index 0362d1b..ce02dac 100644 --- a/src/slack/messages.ts +++ b/src/slack/messages.ts @@ -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 { const history = await client.api("conversations.history", { channel: input.ref.channel_id, @@ -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; @@ -157,6 +162,7 @@ export async function fetchChannelHistory( includeReactions?: boolean; withReactions?: string[]; withoutReactions?: string[]; + fetchFileInfo?: boolean; }, ): Promise { const raw = input.limit ?? 25; @@ -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({ @@ -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 { const out: SlackMessageSummary[] = []; let cursor: string | undefined; @@ -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({ diff --git a/test/message-read-actions.test.ts b/test/message-read-actions.test.ts index 7906629..bb86ff2 100644 --- a/test/message-read-actions.test.ts +++ b/test/message-read-actions.test.ts @@ -50,6 +50,14 @@ function hostedFile(id: string, metadata: { name: string; mimetype: string }) { }; } +function snippetFile(id: string, metadata: { name: string; mimetype: string }) { + return { + id, + ...metadata, + mode: "snippet", + }; +} + function setFetchMock(fn: (...args: unknown[]) => Promise) { const mocked = mock(fn) as unknown as typeof globalThis.fetch; mocked.preconnect = () => {}; @@ -79,9 +87,9 @@ function firstFile(result: Record, messageIndex = 0): Record, - expected: { name: string; mimetype: string }, + expected: { name: string; mimetype: string; mode?: string }, ) { - expect(file).toEqual({ ...expected, mode: "hosted" }); + expect(file).toEqual({ mode: "hosted", ...expected }); expect(file).not.toHaveProperty("path"); expect(file).not.toHaveProperty("error"); } @@ -96,11 +104,15 @@ describe("message list attachment downloads", () => { afterEach(async () => { globalThis.fetch = originalFetch; - process.env.XDG_RUNTIME_DIR = originalXdgRuntimeDir; + if (originalXdgRuntimeDir === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = originalXdgRuntimeDir; + } await rm(runtimeDir, { recursive: true, force: true }); }); - test("channel history keeps file metadata without fetching bodies", async () => { + test("channel history keeps snippet metadata without fetching file info or bodies", async () => { const { ctx, calls } = createContext({ "conversations.history": { messages: [ @@ -109,7 +121,7 @@ describe("message list attachment downloads", () => { text: "metadata only", user: "U11111111", files: [ - hostedFile("FCHANNEL", { + snippetFile("FCHANNEL", { name: "oversized.bin", mimetype: "application/octet-stream", }), @@ -141,13 +153,26 @@ describe("message list attachment downloads", () => { expectMetadataOnly(firstFile(result), { name: "oversized.bin", mimetype: "application/octet-stream", + mode: "snippet", }); }); - test("URL thread listing keeps file metadata without fetching bodies", async () => { + test("URL thread listing skips file info and body fetches in metadata-only mode", async () => { const { ctx, calls } = createContext({ "conversations.history": { - messages: [{ ts: ROOT_TS, text: "thread root", user: "U11111111" }], + messages: [ + { + ts: ROOT_TS, + text: "thread root", + user: "U11111111", + files: [ + snippetFile("FROOT", { + name: "root-snippet.txt", + mimetype: "text/plain", + }), + ], + }, + ], }, "conversations.replies": { messages: [ @@ -188,15 +213,15 @@ describe("message list attachment downloads", () => { }); }); - test("channel thread listing keeps file metadata without fetching bodies", async () => { - const { ctx } = createContext({ + test("channel thread listing keeps snippet metadata without fetching file info or bodies", async () => { + const { ctx, calls } = createContext({ "conversations.replies": { messages: [ { ts: ROOT_TS, text: "metadata-only channel thread", files: [ - hostedFile("FCHANNELTHREAD", { + snippetFile("FCHANNELTHREAD", { name: "thread-archive.zip", mimetype: "application/zip", }), @@ -213,6 +238,7 @@ describe("message list attachment downloads", () => { download: false, }); + expect(calls).toEqual(["conversations.replies"]); expect(fetchMock).not.toHaveBeenCalled(); expect(result).toMatchObject({ messages: [{ ts: ROOT_TS, content: "metadata-only channel thread" }], @@ -220,13 +246,26 @@ describe("message list attachment downloads", () => { expectMetadataOnly(firstFile(result), { name: "thread-archive.zip", mimetype: "application/zip", + mode: "snippet", }); }); - test("channel --ts thread resolution does not fetch file bodies", async () => { + test("channel --ts thread resolution does not fetch file info or bodies", async () => { const { ctx, calls } = createContext({ "conversations.history": { - messages: [{ ts: REPLY_TS, thread_ts: ROOT_TS, text: "selected reply" }], + messages: [ + { + ts: REPLY_TS, + thread_ts: ROOT_TS, + text: "selected reply", + files: [ + snippetFile("FSELECTED", { + name: "selected-snippet.txt", + mimetype: "text/plain", + }), + ], + }, + ], }, "conversations.replies": { messages: [