diff --git a/README.md b/README.md index c574664..253368e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ nori-slack describe chat.postMessage | `--json-input` | Read parameters as JSON from stdin (CLI flags override stdin values). | | `--paginate` | Use cursor pagination and return a single merged JSON response. | | `--dry-run` | Resolve params and print the planned request without calling the API. | +| `--output ` | For `files.download` only: decode the returned bytes and write them to `` instead of printing base64. See [Downloading files](#downloading-files). | ### Exit codes @@ -104,6 +105,32 @@ When both are configured, **proxy mode wins**. All CLI features (`--json-input`, In direct mode, capability boundaries come from the bot token's OAuth scopes. In proxy mode, the broker additionally restricts methods and channels to the session's access grant — requests outside the grant fail with a structured `proxy_error`. +## Downloading files + +`files.download` is the one place this CLI is *not* a 1:1 Bolt mapping. Slack does not expose a "download" Web API method — file bytes live behind authenticated `url_private` URLs — so this CLI adds a convenience method that does the two-step dance for you. + +It works in **both transports**: + +- **Direct mode** (`SLACK_BOT_TOKEN`): the CLI calls `files.info` to resolve the file's `url_private_download` URL, then fetches the bytes with the bot token. Requires the `files:read` scope. +- **Proxy mode** (`NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`): the Nori Sessions broker performs the same fetch on the session's behalf, because a scoped session never holds the raw bot token needed for `url_private`. + +Given a file ID (from `files.info`, `conversations.history`, or a message's `files[]`), it returns the bytes base64-encoded: + +```bash +# Raw response: { ok, file: { id, name, mimetype, contentType, contentBase64 } } +nori-slack files.download --file F0123456789 +``` + +Pass `--output ` to decode the base64 and write the bytes straight to disk. The response is then a summary that omits the (large) base64 blob: + +```bash +# Writes the decoded bytes to ./icon.png +nori-slack files.download --file F0123456789 --output ./icon.png +# stdout: { ok: true, file: { id, name, mimetype, contentType, bytes, path } } +``` + +`--output` is the recommended path for binary files — it avoids piping a multi-megabyte base64 string through the shell. + ## License See [LICENSE](LICENSE) and [LICENSE-ADDENDUM.txt](LICENSE-ADDENDUM.txt). diff --git a/src/docs.md b/src/docs.md index 6543815..0678a58 100644 --- a/src/docs.md +++ b/src/docs.md @@ -10,25 +10,34 @@ Path: @/src - [index.ts](index.ts) is the CLI entry point (shebang `#!/usr/bin/env node`), compiled to `dist/index.js` and exposed as the `nori-slack` binary via the `bin` field in [../package.json](../package.json). The compiled `dist/` directory is produced at pack time by the `prepare` script and shipped to the npm registry via the `files` allowlist -- see [../docs.md](../docs.md) for the full packaging chain - [transport.ts](transport.ts) is the only module that knows how to reach Slack. It selects between proxy mode (a Nori Sessions broker, configured by `NORI_SLACK_PROXY_URL` + `NORI_SLACK_CONTEXT_TOKEN`) and direct mode (`SLACK_BOT_TOKEN` via `@slack/web-api`); everything downstream works against its `Transport` interface - [parse-args.ts](parse-args.ts) and [errors.ts](errors.ts) are pure utility modules with no side effects; [paginate.ts](paginate.ts) is transport-generic (no Slack SDK dependency). All are independently testable and tested in [@/test](../test/) -- [methods.ts](methods.ts) is a static data file; it is only used by the `list-methods` subcommand and has no effect on which methods the CLI can actually call +- [methods.ts](methods.ts) is a static data file backing discoverability (`list-methods`) and unknown-method warnings via `isKnownMethod`; `KNOWN_METHODS` has no effect on which real Slack methods the CLI can call, and `CLI_METHODS` lists the CLI's own convenience methods (currently just `files.download`) so they are recognized by discovery alongside real Slack methods ### Core Implementation **Entry point (`index.ts`)** - Sets up Commander with three subcommands: `list-methods`, `describe`, and the default dynamic method handler -- The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into three paths: +- The dynamic handler: optionally reads JSON from stdin, parses CLI flags, merges params (CLI flags win over stdin), then branches into these paths: 1. `--dry-run`: short-circuits immediately after param resolution -- outputs a JSON preview with `ok`, `dry_run`, `method`, `params`, `transport`, `token_present`, `paginate`, and optionally a `warning` for unknown methods. Does not require credentials. Always exits 0. - 2. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))` - 3. Default: resolves the transport, then makes a single `transport.call(method, params)` + 2. `files.download`: resolves the transport, then calls `transport.download(params.file)` regardless of `--paginate` (the method is not paginated) + 3. `--paginate`: resolves the transport via `resolveTransport()`, then runs `mergePages(paginatePages(transport, method, params))` + 4. Default: resolves the transport, then makes a single `transport.call(method, params)` - If `resolveTransport()` returns null (no credentials in either mode), the handler emits the `no_token` error envelope and exits 1 before any API path runs +- `files.download` plus `--output `: after the download returns, the base64 `file.contentBase64` is decoded and written to the resolved path with `writeFileSync`, and the printed JSON is replaced with a summary (`ok`, `file: { id, name, mimetype, contentType, bytes, path }`) that omits the base64 blob. Without `--output` the raw response (including `contentBase64`) is printed as-is - When no arguments are provided (`process.argv.length <= 2`), help text and error go to stderr and the process exits with code 2 - The `list-methods` subcommand supports two options that compose together: `--namespace ` filters the method list to those starting with the given prefix (e.g., `chat.`), and `--descriptions` changes the output shape from `string[]` to `Array<{ method, description }>` by pulling descriptions from `getMethodMetadata()`. When `--namespace` is provided, a `namespace` field is added to the response JSON. **Transport selection (`transport.ts`)** - `detectTransportMode(env)` returns `'proxy' | 'direct' | 'none'`: proxy when both `NORI_SLACK_PROXY_URL` and `NORI_SLACK_CONTEXT_TOKEN` are non-empty, otherwise direct when `SLACK_BOT_TOKEN` is set, otherwise none. Proxy takes precedence over a bot token -- `resolveTransport(env)` returns a `Transport` (`{ mode, call(method, params) }`) or `null` when no credentials are available +- `resolveTransport(env)` returns a `Transport` (`{ mode, call(method, params), download(fileId) }`) or `null` when no credentials are available - Proxy `call` POSTs `{ method, args }` as JSON to `/method` (trailing slashes are stripped from the configured URL first) with an `authorization: Bearer ` header. A 2xx response is returned as the raw Slack JSON body; a non-2xx response throws `ProxyError` (code `nori_slack_proxy_error`) carrying the HTTP status and the broker's `error` message - Direct `call` wraps `WebClient.apiCall` from `@slack/web-api` +- `download(fileId)` is a CLI convenience method (Slack has no download Web API method). Proxy `download` delegates to the broker via `call('files.download', { file })`, since a scoped session never holds the raw bot token. Direct `download` runs `downloadFileDirect` (from [download.ts](download.ts)) with two injected dependencies: a `files.info` call and an authenticated `fetch` of the file URL with the bot token. Both modes return `{ ok, file: { id, name, mimetype, contentType, contentBase64 } }` + +**Direct-mode file download (`download.ts`)** +- `downloadFileDirect(fileId, filesInfo, httpGet)` is a pure function with both Slack-facing operations injected, so it is unit-tested without a token or network (see [@/test/download.test.ts](../test/download.test.ts)) +- It calls `filesInfo(fileId)`, resolves the file's `url_private_download ?? url_private`, then `httpGet(url)` to fetch the bytes; `contentType` falls back to the file's `mimetype` when the response has none +- It throws (no error `code`, so the CLI surfaces a generic `unknown_error`) when: the file has no downloadable URL, the HTTP status is non-ok, or the response is `text/html` (Slack's sign-in page, meaning the token lacks `files:read` scope or access) +- The direct transport supplies `httpGet` as a `fetch` with an `authorization: Bearer ` header; a successful authenticated request returns the bytes at HTTP 200 with no cross-origin redirect **Pagination (`paginate.ts`)** - `paginatePages(transport, method, params)` is an async generator that repeatedly calls the transport, following `response_metadata.next_cursor` and terminating when the cursor is empty or missing. It replaces the old `WebClient.paginate()` path so pagination works identically over both transports @@ -43,7 +52,7 @@ Path: @/src **Error formatting (`errors.ts`)** - `formatError(error, sourceDir)` returns a `CliError` object with fields: `ok`, `error`, `message`, `suggestion`, `source` -- Handles five specific error codes: the `@slack/web-api` codes `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, and `slack_webapi_request_error`, plus the custom `no_token` and the proxy transport's `nori_slack_proxy_error` +- Handles five specific error codes: the `@slack/web-api` codes `slack_webapi_platform_error`, `slack_webapi_rate_limited_error`, and `slack_webapi_request_error`, plus the custom `no_token` and the proxy transport's `nori_slack_proxy_error`. Errors thrown by `downloadFileDirect` (missing URL, non-ok HTTP status, HTML sign-in page) carry no `code` and fall through to the generic `unknown_error` envelope with their message preserved - For `nori_slack_proxy_error`: broker messages of the form "An API error occurred: \" have the Slack platform code extracted and mapped through the same `SUGGESTIONS` table as direct-mode platform errors; HTTP 401 maps to `proxy_unauthorized` with a context-token rotation suggestion; any other status maps to `proxy_error` with a suggestion about the session's access grant - Broker wire contract behind those mappings: 200 returns raw Slack JSON; error statuses (e.g., 401, 403, 404) return `{ error: message }` - The `SUGGESTIONS` map provides agent-friendly remediation text for common Slack platform errors like `channel_not_found`, `not_in_channel`, `rate_limited`, etc. @@ -57,17 +66,19 @@ Path: @/src **Methods catalog (`methods.ts`)** - `KNOWN_METHODS` is a static string array of Slack Web API methods available to bot tokens - Serves as a discoverability aid only; the comment in the file explicitly notes the CLI is not limited to these methods +- `CLI_METHODS` is a separate static array of the CLI's own convenience methods that are *not* real Slack Web API methods (currently just `files.download`). They are dispatched through dedicated transport logic rather than `apiCall`, and work in both transports +- `isKnownMethod(method)` returns true when the method is in either `KNOWN_METHODS` or `CLI_METHODS`; it backs `list-methods`, the unknown-method warnings, and the `--dry-run` warning so CLI convenience methods are not flagged as unknown **Method metadata (`method-metadata.ts`)** -- `METHOD_METADATA` is a static `Record` map providing parameter documentation for every method in `KNOWN_METHODS` +- `METHOD_METADATA` is a static `Record` map providing parameter documentation for every method in `KNOWN_METHODS`, plus the `files.download` convenience method - Each entry includes: `description`, `required_params`, `optional_params` (both `Record` mapping param name to human-readable description), `supports_pagination` (boolean), optional `deprecated` notice, and `docs_url` - `getMethodMetadata(method)` looks up the map and returns the entry if present; for unknown methods, it returns a fallback with empty params and a generated docs URL -- The `docsUrl()` helper constructs URLs in the form `https://api.slack.com/methods/{method}` +- The `docsUrl()` helper constructs URLs in the form `https://api.slack.com/methods/{method}`; the `files.download` entry overrides this with a GitHub README anchor since it is a CLI convenience method, not a real Slack method - The `describe` command in [index.ts](index.ts) wraps `getMethodMetadata` output with `ok`, `method`, and `known` fields (where `known` is `true` only when the method has a curated entry in `METHOD_METADATA`) ### Things to Know -- `--json-input`, `--paginate`, and `--dry-run` are consumed by Commander as known options; all other flags pass through via `allowUnknownOption()` and are parsed by `parseArgs` from `process.argv` -- The raw args filter explicitly strips `--json-input`, `--paginate`, and `--dry-run` before passing to `parseArgs`, preventing them from being sent as Slack API parameters +- `--json-input`, `--paginate`, `--dry-run`, and `--output ` are consumed by Commander as known options; all other flags pass through via `allowUnknownOption()` and are parsed by `parseArgs` from `process.argv` +- The raw args filter strips the boolean options (`--json-input`, `--paginate`, `--dry-run`) and the value option `--output` together with its following value (and the `--output=value` form) before passing to `parseArgs`, preventing any of them from being sent as Slack API parameters - When both stdin JSON and CLI flags provide the same key, the CLI flag value wins due to spread order: `{ ...stdinParams, ...cliParams }` - Non-flag arguments (tokens not starting with `--`) are silently skipped by `parseArgs` -- they do not cause errors - Rate limit errors extract `retryAfter` from the `@slack/web-api` error object and include the retry duration in the message diff --git a/src/download.ts b/src/download.ts new file mode 100644 index 0000000..c6368f8 --- /dev/null +++ b/src/download.ts @@ -0,0 +1,66 @@ +// Direct-mode file download. Not a Slack Web API method: it resolves a file's +// authenticated URL via files.info and fetches the bytes with the bot token. +// The Slack-facing I/O (files.info, the HTTP GET) is injected so the logic is +// testable without a token or network. + +export interface DownloadResult { + ok: true; + file: { + id: string; + name?: string; + mimetype?: string; + contentType?: string; + contentBase64: string; + }; +} + +export interface HttpDownload { + ok: boolean; + status: number; + contentType: string | null; + bytes: Buffer; +} + +export async function downloadFileDirect( + fileId: string, + filesInfo: (file: string) => Promise>, + httpGet: (url: string) => Promise +): Promise { + const info = await filesInfo(fileId); + const file = info?.file; + if (!file) { + throw new Error(`files.info returned no file for '${fileId}'.`); + } + + const url: string | undefined = file.url_private_download ?? file.url_private; + if (!url) { + throw new Error( + `File '${fileId}' has no downloadable URL (url_private_download / url_private).` + ); + } + + const download = await httpGet(url); + if (!download.ok) { + throw new Error(`Failed to download file '${fileId}': HTTP ${download.status}.`); + } + + // A text/html 200 is Slack's sign-in page, not file bytes — the token is + // missing the files:read scope or has no access to this file. + if (download.contentType && download.contentType.includes('text/html')) { + throw new Error( + `Slack returned an HTML page instead of file bytes for '${fileId}'. The bot token likely lacks the files:read scope or access to this file.` + ); + } + + const contentType = download.contentType ?? file.mimetype ?? undefined; + return { + ok: true, + file: { + id: file.id ?? fileId, + name: file.name, + mimetype: file.mimetype, + contentType, + contentBase64: download.bytes.toString('base64'), + }, + }; +} diff --git a/src/index.ts b/src/index.ts index ced0040..52c6d3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,13 @@ import { Command } from 'commander'; import { parseArgs } from './parse-args.js'; import { formatError } from './errors.js'; -import { KNOWN_METHODS } from './methods.js'; +import { KNOWN_METHODS, CLI_METHODS, isKnownMethod } from './methods.js'; import { mergePages, paginatePages } from './paginate.js'; import { detectTransportMode, resolveTransport } from './transport.js'; import { getMethodMetadata, METHOD_METADATA } from './method-metadata.js'; import { findSimilarMethods } from './suggest.js'; import { fileURLToPath } from 'node:url'; +import { writeFileSync } from 'node:fs'; import path from 'node:path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -27,7 +28,7 @@ program .option('--namespace ', 'Filter methods by namespace prefix (e.g., "chat", "conversations", "files")') .option('--descriptions', 'Include a short description for each method') .action((opts: { namespace?: string; descriptions?: boolean }) => { - let methods = KNOWN_METHODS; + let methods = [...KNOWN_METHODS, ...CLI_METHODS]; if (opts.namespace) { const prefix = opts.namespace + '.'; @@ -77,6 +78,7 @@ program .option('--json-input', 'Read parameters as JSON from stdin') .option('--paginate', 'Automatically fetch all pages and merge results') .option('--dry-run', 'Preview the API request without sending it. Shows method, resolved params, and token status.') + .option('--output ', 'For files.download: decode the returned bytes and write them to this path instead of printing base64.') .allowUnknownOption(true) .allowExcessArguments(true) .action(async (method: string, opts: Record) => { @@ -110,10 +112,23 @@ program } } - // Parse CLI args directly from process.argv, skipping node, script, and method - const CLI_OPTIONS = ['--json-input', '--paginate', '--dry-run']; + // Parse CLI args directly from process.argv, skipping node, script, method, + // and the CLI's own flags so they don't leak into the Slack API params. + const BOOLEAN_CLI_OPTIONS = ['--json-input', '--paginate', '--dry-run']; + const VALUE_CLI_OPTIONS = ['--output']; const methodIndex = process.argv.indexOf(method); - const rawArgs = methodIndex >= 0 ? process.argv.slice(methodIndex + 1).filter(a => !CLI_OPTIONS.includes(a)) : []; + const rawSlice = methodIndex >= 0 ? process.argv.slice(methodIndex + 1) : []; + const rawArgs: string[] = []; + for (let i = 0; i < rawSlice.length; i++) { + const arg = rawSlice[i]; + if (BOOLEAN_CLI_OPTIONS.includes(arg)) continue; + if (VALUE_CLI_OPTIONS.includes(arg)) { + i++; // also skip this option's value + continue; + } + if (VALUE_CLI_OPTIONS.some(opt => arg.startsWith(opt + '='))) continue; + rawArgs.push(arg); + } const cliParams = parseArgs(rawArgs); params = { ...params, ...cliParams }; @@ -127,7 +142,7 @@ program token_present: !!process.env.SLACK_BOT_TOKEN, paginate: !!opts.paginate, }; - if (!KNOWN_METHODS.includes(method)) { + if (!isKnownMethod(method)) { const suggestions = findSimilarMethods(method); const didYouMean = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : ''; dryRunResult.warning = `Method '${method}' is not in the known methods list.${didYouMean} It may still be valid.`; @@ -146,7 +161,7 @@ program process.exit(1); } - if (!KNOWN_METHODS.includes(method)) { + if (!isKnownMethod(method)) { const suggestions = findSimilarMethods(method); if (suggestions.length > 0) { process.stderr.write(`Warning: Method '${method}' is not in the known methods list. Did you mean: ${suggestions.join(', ')}?\n`); @@ -155,11 +170,32 @@ program try { let result; - if (opts.paginate) { + if (method === 'files.download') { + result = await transport.download(String(params.file ?? '')); + } else if (opts.paginate) { result = await mergePages(paginatePages(transport, method, params)); } else { result = await transport.call(method, params); } + + if (method === 'files.download' && opts.output && typeof result?.file?.contentBase64 === 'string') { + const file = result.file; + const outPath = path.resolve(process.cwd(), opts.output); + const bytes = Buffer.from(file.contentBase64, 'base64'); + writeFileSync(outPath, bytes); + result = { + ok: result.ok ?? true, + file: { + id: file.id, + name: file.name, + mimetype: file.mimetype, + contentType: file.contentType, + bytes: bytes.length, + path: outPath, + }, + }; + } + process.stdout.write(JSON.stringify(result) + '\n'); } catch (err) { const error = formatError(err, SOURCE_DIR); diff --git a/src/method-metadata.ts b/src/method-metadata.ts index 329b780..95f31a6 100644 --- a/src/method-metadata.ts +++ b/src/method-metadata.ts @@ -647,6 +647,15 @@ export const METHOD_METADATA: Record = { supports_pagination: false, docs_url: docsUrl('files.delete'), }, + 'files.download': { + description: 'Downloads a file\'s bytes by ID. Not a Slack Web API method: in direct mode the CLI reads url_private_download from files.info and fetches it with SLACK_BOT_TOKEN (requires the files:read scope); in proxy mode the Nori Sessions broker fetches the bytes on the session\'s behalf. Returns the bytes base64-encoded in file.contentBase64; pass --output to decode and write them to disk instead.', + required_params: { + file: 'File ID (e.g., F0123456789)', + }, + optional_params: {}, + supports_pagination: false, + docs_url: 'https://github.com/tilework-tech/nori-slack-cli#downloading-files', + }, 'files.info': { description: 'Gets information about a file.', required_params: { diff --git a/src/methods.ts b/src/methods.ts index 017af11..61d568e 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -122,3 +122,16 @@ export const KNOWN_METHODS = [ 'workflows.stepFailed', 'workflows.updateStep', ]; + +// CLI convenience methods that are not part of the Slack Web API. They are +// tracked separately from KNOWN_METHODS so discovery (list-methods, describe) +// and unknown-method warnings recognize them. files.download works in both +// transports: direct mode reads url_private_download from files.info and +// fetches it with the bot token; proxy mode delegates to the broker. +export const CLI_METHODS = [ + 'files.download', +]; + +export function isKnownMethod(method: string): boolean { + return KNOWN_METHODS.includes(method) || CLI_METHODS.includes(method); +} diff --git a/src/transport.ts b/src/transport.ts index a7fd192..bd0d52c 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -1,10 +1,12 @@ import { WebClient } from '@slack/web-api'; +import { downloadFileDirect, type HttpDownload } from './download.js'; export type TransportMode = 'proxy' | 'direct' | 'none'; export interface Transport { mode: 'proxy' | 'direct'; call(method: string, params: Record): Promise>; + download(fileId: string): Promise>; } export const PROXY_ERROR_CODE = 'nori_slack_proxy_error'; @@ -31,39 +33,64 @@ export function resolveTransport(env: NodeJS.ProcessEnv = process.env): Transpor if (mode === 'proxy') { const baseUrl = env.NORI_SLACK_PROXY_URL!.replace(/\/+$/, ''); const contextToken = env.NORI_SLACK_CONTEXT_TOKEN!; + const call = async (method: string, params: Record) => { + const res = await fetch(`${baseUrl}/method`, { + method: 'POST', + headers: { + authorization: `Bearer ${contextToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ method, args: params }), + }); + const text = await res.text(); + let body: any = null; + try { + body = JSON.parse(text); + } catch { + // Non-JSON body: fall through with the raw text as the error message. + } + if (!res.ok) { + throw new ProxyError(res.status, typeof body?.error === 'string' ? body.error : text); + } + return body; + }; return { mode, - async call(method, params) { - const res = await fetch(`${baseUrl}/method`, { - method: 'POST', - headers: { - authorization: `Bearer ${contextToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ method, args: params }), - }); - const text = await res.text(); - let body: any = null; - try { - body = JSON.parse(text); - } catch { - // Non-JSON body: fall through with the raw text as the error message. - } - if (!res.ok) { - throw new ProxyError(res.status, typeof body?.error === 'string' ? body.error : text); - } - return body; + call, + // The broker fetches the bytes on the session's behalf, since a scoped + // session never holds the raw bot token needed for url_private. + download(fileId) { + return call('files.download', { file: fileId }); }, }; } if (mode === 'direct') { - const client = new WebClient(env.SLACK_BOT_TOKEN); + const token = env.SLACK_BOT_TOKEN!; + const client = new WebClient(token); return { mode, call(method, params) { return client.apiCall(method, params) as Promise>; }, + download(fileId) { + return downloadFileDirect( + fileId, + (file) => client.apiCall('files.info', { file }) as Promise>, + async (url): Promise => { + const res = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + }); + const bytes = Buffer.from(await res.arrayBuffer()); + return { + ok: res.ok, + status: res.status, + contentType: res.headers.get('content-type'), + bytes, + }; + } + ); + }, }; } diff --git a/test/download.test.ts b/test/download.test.ts new file mode 100644 index 0000000..0e98534 --- /dev/null +++ b/test/download.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { downloadFileDirect, type HttpDownload } from '../src/download.js'; + +function okInfo(file: Record) { + return async () => ({ ok: true, file }); +} + +describe('downloadFileDirect', () => { + it('fetches url_private_download and returns base64 bytes', async () => { + const bytes = Buffer.from('hello-bytes\x00\x01\x02', 'binary'); + let fetchedUrl = ''; + const result = await downloadFileDirect( + 'F1', + okInfo({ + id: 'F1', + name: 'a.png', + mimetype: 'image/png', + url_private_download: 'https://files.slack.com/F1/download', + }), + async (url): Promise => { + fetchedUrl = url; + return { ok: true, status: 200, contentType: 'image/png', bytes }; + } + ); + + expect(fetchedUrl).toBe('https://files.slack.com/F1/download'); + expect(result.ok).toBe(true); + expect(result.file.id).toBe('F1'); + expect(result.file.name).toBe('a.png'); + expect(result.file.contentType).toBe('image/png'); + expect(result.file.contentBase64).toBe(bytes.toString('base64')); + }); + + it('falls back to url_private when url_private_download is absent', async () => { + let fetchedUrl = ''; + await downloadFileDirect( + 'F2', + okInfo({ id: 'F2', url_private: 'https://files.slack.com/F2/priv' }), + async (url): Promise => { + fetchedUrl = url; + return { ok: true, status: 200, contentType: 'application/pdf', bytes: Buffer.from('x') }; + } + ); + + expect(fetchedUrl).toBe('https://files.slack.com/F2/priv'); + }); + + it('falls back to file.mimetype when the response has no content-type', async () => { + const result = await downloadFileDirect( + 'F3', + okInfo({ id: 'F3', mimetype: 'image/gif', url_private_download: 'u' }), + async (): Promise => ({ + ok: true, + status: 200, + contentType: null, + bytes: Buffer.from('x'), + }) + ); + + expect(result.file.contentType).toBe('image/gif'); + }); + + it('throws when the file has no downloadable URL', async () => { + await expect( + downloadFileDirect('F4', okInfo({ id: 'F4' }), async () => { + throw new Error('should not fetch'); + }) + ).rejects.toThrow(/no downloadable URL/i); + }); + + it('throws on a non-ok HTTP status', async () => { + await expect( + downloadFileDirect( + 'F5', + okInfo({ id: 'F5', url_private_download: 'u' }), + async (): Promise => ({ + ok: false, + status: 403, + contentType: null, + bytes: Buffer.alloc(0), + }) + ) + ).rejects.toThrow(/HTTP 403/); + }); + + it('throws when Slack returns an HTML sign-in page instead of bytes', async () => { + await expect( + downloadFileDirect( + 'F6', + okInfo({ id: 'F6', url_private_download: 'u' }), + async (): Promise => ({ + ok: true, + status: 200, + contentType: 'text/html; charset=utf-8', + bytes: Buffer.from('signin'), + }) + ) + ).rejects.toThrow(/HTML page/i); + }); +}); diff --git a/test/files-download.test.ts b/test/files-download.test.ts new file mode 100644 index 0000000..928c13e --- /dev/null +++ b/test/files-download.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync, rmSync, mkdtempSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runCli, startFakeBroker, type FakeBroker } from './helpers.js'; + +describe('files.download', () => { + let broker: FakeBroker; + let tmpDir: string; + + beforeEach(async () => { + broker = await startFakeBroker(); + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'nori-slack-dl-')); + }); + + afterEach(async () => { + await broker.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function proxyEnv(extra: Record = {}): Record { + return { + NORI_SLACK_PROXY_URL: broker.url, + NORI_SLACK_CONTEXT_TOKEN: 'ctx-token-123', + ...extra, + }; + } + + it('--output decodes contentBase64 to disk and prints a summary without the base64 blob', async () => { + const fileBytes = Buffer.from('the-real-file-bytes\x00\x01\x02', 'binary'); + broker.queueResponse({ + body: { + ok: true, + file: { + id: 'F1', + name: 'icon.png', + mimetype: 'image/png', + contentType: 'image/png', + contentBase64: fileBytes.toString('base64'), + }, + }, + }); + + const outPath = path.join(tmpDir, 'icon.png'); + const result = await runCli( + ['files.download', '--file', 'F1', '--output', outPath], + proxyEnv() + ); + + expect(result.exitCode).toBe(0); + + // Request reached the broker as a proper files.download call. + expect(broker.requests).toHaveLength(1); + expect(broker.requests[0].body).toEqual({ + method: 'files.download', + args: { file: 'F1' }, + }); + + // Bytes landed on disk, decoded. + expect(existsSync(outPath)).toBe(true); + expect(readFileSync(outPath).equals(fileBytes)).toBe(true); + + // stdout summary describes the written file and omits the base64 blob. + const output = JSON.parse(result.stdout); + expect(output.ok).toBe(true); + expect(output.file.path).toBe(outPath); + expect(output.file.bytes).toBe(fileBytes.length); + expect(output.file.name).toBe('icon.png'); + expect(output.file.contentBase64).toBeUndefined(); + }); + + it('is a recognized method — no "not in the known methods list" warning', async () => { + broker.queueResponse({ + body: { ok: true, file: { id: 'F1', name: 'a.txt', contentBase64: '' } }, + }); + const result = await runCli(['files.download', '--file', 'F1'], proxyEnv()); + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain('known methods list'); + }); + + it('describe reports it as a known download method', async () => { + const result = await runCli(['describe', 'files.download'], {}); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.known).toBe(true); + expect(output.description.toLowerCase()).toContain('download'); + expect(output.required_params.file).toBeDefined(); + }); + + it('list-methods --namespace files includes it', async () => { + const result = await runCli(['list-methods', '--namespace', 'files'], {}); + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.methods).toContain('files.download'); + }); +});