diff --git a/scripts/src/public-api-methods.ts b/scripts/src/public-api-methods.ts index eec7185..399778c 100644 --- a/scripts/src/public-api-methods.ts +++ b/scripts/src/public-api-methods.ts @@ -154,6 +154,8 @@ export const getPublicAPIMethods = () => { "files.list", "files.revokePublicURL", "files.sharedPublicURL", + "files.getUploadURLExternal", + "files.completeUploadExternal", "files.upload", "files.remote.add", "files.remote.info", diff --git a/src/api-proxy.ts b/src/api-proxy.ts index 59cc385..2cbc677 100644 --- a/src/api-proxy.ts +++ b/src/api-proxy.ts @@ -16,6 +16,7 @@ export const ProxifyAndTypeClient = (baseClient: BaseSlackAPIClient) => { setSlackApiUrl: baseClient.setSlackApiUrl.bind(baseClient), apiCall: baseClient.apiCall.bind(baseClient), response: baseClient.response.bind(baseClient), + fileUploadV2: baseClient.fileUploadV2.bind(baseClient), }; // Create our proxy, and type it w/ our api method types diff --git a/src/api_test.ts b/src/api_test.ts index ddf3e52..8444531 100644 --- a/src/api_test.ts +++ b/src/api_test.ts @@ -365,6 +365,201 @@ Deno.test("SlackAPI class", async (t) => { }, ); + await t.step( + "fileUploadV2 method", + async (t) => { + const client = SlackAPI("test-token"); + await t.step( + "should successfully upload a single file", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":true}`, + ); + }); + const response = await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }); + response.forEach((res) => assertEquals(res.ok, true)); + + mf.reset(); + }, + ); + + await t.step( + "should successfully upload multiple file", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + const testTextFile = { + file: "test", + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":true}`, + ); + }); + const response = await client.fileUploadV2({ + file_uploads: [ + testFile, + testTextFile, + ], + }); + response.forEach((res) => assertEquals(res.ok, true)); + + mf.reset(); + }, + ); + await t.step( + "should rejects when get upload url fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": false, + }), + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + await t.step( + "should rejects when upload fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 500 }, + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + await t.step( + "should rejects when upload complete fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":false}`, + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + }, + ); + mf.uninstall(); }); diff --git a/src/base-client.ts b/src/base-client.ts index c952d75..5ab1be9 100644 --- a/src/base-client.ts +++ b/src/base-client.ts @@ -6,6 +6,7 @@ import { } from "./types.ts"; import { createHttpError, HttpError } from "./deps.ts"; import { getUserAgent, serializeData } from "./base-client-helpers.ts"; +import { FileUploadV2, FileUploadV2Args } from "./typed-method-types/files.ts"; export class BaseSlackAPIClient implements BaseSlackClient { #token?: string; @@ -72,6 +73,83 @@ export class BaseSlackAPIClient implements BaseSlackClient { return await this.createBaseResponse(response); } + async fileUploadV2( + args: FileUploadV2Args, + ) { + const { file_uploads } = args; + const uploadUrls = await Promise.all( + file_uploads.map((file) => this.getFileUploadUrl(file)), + ); + + await Promise.all( + uploadUrls.map((uploadUrl, index) => + this.uploadFile(uploadUrl.upload_url, file_uploads[index].file) + ), + ); + + return await Promise.all( + uploadUrls.map((uploadUrl, index) => + this.completeFileUpload(uploadUrl.file_id, file_uploads[index]) + ), + ); + } + + private async getFileUploadUrl(file: FileUploadV2) { + const fileMetaData = { + filename: file.filename, + length: file.length, + alt_text: file.alt_text, + snippet_type: file.snippet_type, + }; + const response = await this.apiCall( + "files.getUploadURLExternal", + fileMetaData, + ); + + if (!response.ok) { + throw new Error(JSON.stringify(response.response_metadata)); + } + return response; + } + + private async completeFileUpload(fileID: string, file: FileUploadV2) { + const fileMetaData = { + files: JSON.stringify([{ id: fileID, title: file.title }]), + channel_id: file.channel_id, + initial_comment: file.initial_comment, + thread_ts: file.thread_ts, + }; + const response = await this.apiCall( + "files.completeUploadExternal", + fileMetaData, + ); + if (!response.ok) { + throw new Error(JSON.stringify(response.response_metadata)); + } + return response; + } + + private async uploadFile( + uploadUrl: string, + file: FileUploadV2["file"], + ) { + const response = await fetch(uploadUrl, { + headers: { + "Content-Type": typeof file === "string" + ? "text/plain" + : "application/octet-stream", + "User-Agent": getUserAgent(), + }, + method: "POST", + body: file, + }); + + if (!response.ok) { + throw await this.createHttpError(response); + } + return; + } + private async createHttpError(response: Response): Promise { const text = await response.text(); return createHttpError( diff --git a/src/dev_deps.ts b/src/dev_deps.ts index 71ecd76..68f530b 100644 --- a/src/dev_deps.ts +++ b/src/dev_deps.ts @@ -4,6 +4,7 @@ export { assertExists, assertInstanceOf, assertRejects, + fail, } from "https://deno.land/std@0.132.0/testing/asserts.ts"; export * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts"; export { isHttpError } from "https://deno.land/std@0.182.0/http/http_errors.ts"; diff --git a/src/generated/method-types/api_method_types_test.ts b/src/generated/method-types/api_method_types_test.ts index 083df9a..eba789d 100644 --- a/src/generated/method-types/api_method_types_test.ts +++ b/src/generated/method-types/api_method_types_test.ts @@ -184,7 +184,9 @@ Deno.test("SlackAPIMethodsType generated types", () => { assertEquals(typeof client.enterprise.auth.idpconfig.remove, "function"); assertEquals(typeof client.enterprise.auth.idpconfig.set, "function"); assertEquals(typeof client.files.comments.delete, "function"); + assertEquals(typeof client.files.completeUploadExternal, "function"); assertEquals(typeof client.files.delete, "function"); + assertEquals(typeof client.files.getUploadURLExternal, "function"); assertEquals(typeof client.files.info, "function"); assertEquals(typeof client.files.list, "function"); assertEquals(typeof client.files.remote.add, "function"); diff --git a/src/generated/method-types/files.ts b/src/generated/method-types/files.ts index 2ecee62..2ddc4fa 100644 --- a/src/generated/method-types/files.ts +++ b/src/generated/method-types/files.ts @@ -7,7 +7,9 @@ export type FilesAPIType = { comments: { delete: SlackAPIMethod; }; + completeUploadExternal: SlackAPIMethod; delete: SlackAPIMethod; + getUploadURLExternal: SlackAPIMethod; info: SlackAPIMethod; list: SlackAPICursorPaginatedMethod; remote: { diff --git a/src/typed-method-types/files.ts b/src/typed-method-types/files.ts new file mode 100644 index 0000000..4d24248 --- /dev/null +++ b/src/typed-method-types/files.ts @@ -0,0 +1,44 @@ +import { BaseResponse } from "../types.ts"; + +interface FileUpload { + /** @description Comma-separated list of channel names or IDs where the file will be shared. */ + channels?: string; + /** @description If omitting this parameter, you must provide a file. */ + content?: string; + /** @description A file type identifier. */ + filetype?: string; + /** @description Provide another message's ts value to upload this file as a reply. Never use a reply's ts value; use its parent instead. */ + thread_ts?: string; + /** @description The message text introducing the file in specified channels. */ + initial_comment?: string; + /** @description Title of the file being uploaded */ + title?: string; + /** @description Name of the file being uploaded. */ + filename?: string; + /** @description Filetype of the file being uploaded. */ + file: Exclude; +} + +// Channels and filetype is no longer a supported field and filename is required for file.uploadV2. +export type FileUploadV2 = + & Omit + & { + channel_id?: string; + /** @description Description of image for screen-reader. */ + alt_text?: string; + /** @description Syntax type of the snippet being uploaded. */ + snippet_type?: string; + /** @description Size in bytes of the file being uploaded. */ + length: string; + /** @description Name of the file being uploaded. */ + filename: string; + }; + +export type FileUploadV2Args = { + file_uploads: FileUploadV2[]; +}; + +export type GetUploadURLExternalResponse = BaseResponse & { + file_id: string; + upload_url: string; +}; diff --git a/src/types.ts b/src/types.ts index 13159e3..4c977bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { TypedSlackAPIMethodsType } from "./typed-method-types/mod.ts"; import { SlackAPIMethodsType } from "./generated/method-types/mod.ts"; +import { FileUploadV2Args } from "./typed-method-types/files.ts"; export type { DatastoreItem } from "./typed-method-types/apps.ts"; @@ -52,6 +53,9 @@ export type BaseSlackClient = { setSlackApiUrl: (slackApiUrl: string) => BaseSlackClient; apiCall: BaseClientCall; response: BaseClientResponse; + fileUploadV2: ( + args: FileUploadV2Args, + ) => Promise; }; // TODO: [brk-chg] return a `Promise` object