diff --git a/src/commands/download.ts b/src/commands/download.ts new file mode 100644 index 0000000..0833099 --- /dev/null +++ b/src/commands/download.ts @@ -0,0 +1,64 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { DownloadOptions } from '../types/commands'; +import { createSlackClient } from '../utils/client-factory'; +import { renderByFormat } from '../utils/command-support'; +import { wrapCommand } from '../utils/command-wrapper'; +import { parseProfile } from '../utils/option-parsers'; +import type { DownloadFileResult } from '../utils/slack-operations/file-operations'; +import { createValidationHook } from '../utils/validators'; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function setupDownloadCommand(): Command { + const downloadCommand = new Command('download') + .description('Download a file from Slack') + .option('-u, --url ', 'File URL (url_private or url_private_download from message)') + .option('-i, --id ', 'Slack file ID (e.g. F0BFXAEP1UZ)') + .option('-o, --output ', 'Output file path (defaults to original filename in current dir)') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--profile ', 'Use specific workspace profile') + .hook( + 'preAction', + createValidationHook([ + (options) => { + if (!options.url && !options.id) { + return 'You must specify either --url or --id'; + } + if (options.url && options.id) { + return 'Cannot use both --url and --id'; + } + return null; + }, + ]) + ) + .action( + wrapCommand(async (options: DownloadOptions) => { + const profile = parseProfile(options.profile); + const client = await createSlackClient(profile); + + const result = await client.downloadFile({ + url: options.url, + fileId: options.id, + outputPath: options.output, + }); + + renderByFormat(options, result, { + table: (data) => { + console.log(chalk.green(`✓ Downloaded: ${data.fileName}`)); + console.log(` path: ${data.filePath}`); + console.log(` size: ${formatFileSize(data.size)}`); + }, + simple: (data) => { + console.log(data.filePath); + }, + }); + }) + ); + + return downloadCommand; +} diff --git a/src/index.ts b/src/index.ts index f88e1d7..4ae0bb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { setupChannelCommand } from './commands/channel'; import { setupChannelsCommand } from './commands/channels'; import { setupConfigCommand } from './commands/config'; import { setupDeleteCommand } from './commands/delete'; +import { setupDownloadCommand } from './commands/download'; import { setupEditCommand } from './commands/edit'; import { setupHistoryCommand } from './commands/history'; import { setupInviteCommand } from './commands/invite'; @@ -56,6 +57,7 @@ export function createProgram(): Command { program.addCommand(setupEditCommand()); program.addCommand(setupDeleteCommand()); program.addCommand(setupUploadCommand()); + program.addCommand(setupDownloadCommand()); program.addCommand(setupReactionCommand()); program.addCommand(setupPinCommand()); program.addCommand(setupUsersCommand()); diff --git a/src/types/commands.ts b/src/types/commands.ts index 9612cd9..077f595 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -253,3 +253,11 @@ export interface CanvasListOptions { format?: 'table' | 'simple' | 'json'; profile?: string; } + +export interface DownloadOptions { + url?: string; + id?: string; + output?: string; + format?: 'table' | 'simple' | 'json'; + profile?: string; +} diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 5ab6b9f..d77bdbf 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -28,7 +28,10 @@ import type { import { createSlackClientContext } from './slack-operations/base-client'; import { CanvasOperations } from './slack-operations/canvas-operations'; import { ChannelOperations } from './slack-operations/channel-operations'; -import type { UploadFileOptions } from './slack-operations/file-operations'; +import type { + DownloadFileOptions, + UploadFileOptions, +} from './slack-operations/file-operations'; import { FileOperations } from './slack-operations/file-operations'; import { MessageOperations } from './slack-operations/message-operations'; import { PinOperations } from './slack-operations/pin-operations'; @@ -167,6 +170,10 @@ export class SlackApiClient { return this.fileOps.uploadFile(options); } + async downloadFile(options: DownloadFileOptions) { + return this.fileOps.downloadFile(options); + } + async addReaction(channel: string, timestamp: string, emoji: string): Promise { return this.reactionOps.addReaction(channel, timestamp, emoji); } diff --git a/src/utils/slack-operations/file-operations.ts b/src/utils/slack-operations/file-operations.ts index d7d6d11..f6c9829 100644 --- a/src/utils/slack-operations/file-operations.ts +++ b/src/utils/slack-operations/file-operations.ts @@ -1,4 +1,6 @@ -import { basename } from 'path'; +import { createWriteStream } from 'fs'; +import { basename, join } from 'path'; +import { pipeline } from 'stream/promises'; import { BaseSlackClient, SlackClientDependency } from './base-client'; import { ChannelOperations } from './channel-operations'; @@ -27,6 +29,19 @@ export interface UploadFileResult { files: UploadedFileInfo[]; } +export interface DownloadFileOptions { + url?: string; + fileId?: string; + outputDir?: string; + outputPath?: string; +} + +export interface DownloadFileResult { + filePath: string; + fileName: string; + size: number; +} + export class FileOperations extends BaseSlackClient { private channelOps: ChannelOperations; @@ -35,6 +50,48 @@ export class FileOperations extends BaseSlackClient { this.channelOps = channelOps ?? new ChannelOperations(dependency); } + async downloadFile(options: DownloadFileOptions): Promise { + let url: string; + let fileName: string; + + if (options.fileId) { + const info = (await this.client.files.info({ file: options.fileId })) as { + file?: { url_private_download?: string; url_private?: string; name?: string }; + }; + url = info.file?.url_private_download || info.file?.url_private || ''; + fileName = info.file?.name || options.fileId; + if (!url) throw new Error('No download URL found for this file'); + } else if (options.url) { + url = options.url; + const urlPath = new URL(url).pathname; + fileName = decodeURIComponent(basename(urlPath)); + } else { + throw new Error('Either --url or --id is required'); + } + + const outputPath = options.outputPath || join(options.outputDir || '.', fileName); + const token = this.client.token; + if (!token) throw new Error('No token available'); + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + if (!response.body) { + throw new Error('No response body'); + } + + const fileStream = createWriteStream(outputPath); + await pipeline(response.body as unknown as NodeJS.ReadableStream, fileStream); + + const { size } = await import('fs').then((fs) => fs.promises.stat(outputPath)); + + return { filePath: outputPath, fileName, size }; + } + async uploadFile(options: UploadFileOptions): Promise { const channelId = await this.channelOps.resolveChannelId(options.channel);