From 2bb7bad9475a47605f5009957edd5d7903fb94fb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 30 Jan 2023 13:25:55 +0000 Subject: [PATCH] =?UTF-8?q?Breakout=20slack=20into=20it=E2=80=99s=20own=20?= =?UTF-8?q?package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../performIntegrationRequest.server.ts | 3 +- .../slack/handleInteractivity.server.ts | 4 +- apps/webapp/remix.config.js | 1 + apps/webapp/tsconfig.json | 4 +- config-packages/tsconfig/examples.json | 6 + examples/fetch-playground/package.json | 2 +- examples/fetch-playground/src/index.ts | 2 +- examples/schedule-to-slack/package.json | 2 +- examples/schedule-to-slack/src/index.ts | 10 +- examples/send-to-slack/package.json | 4 +- examples/send-to-slack/src/index.tsx | 2 +- examples/smoke-test/package.json | 3 +- integrations/github/src/internal.ts | 10 +- .../github/src/{ => internal}/webhooks.ts | 2 +- integrations/github/tsconfig.json | 2 +- integrations/slack/README.md | 3 + integrations/slack/manifest.json | 35 + integrations/slack/package.json | 41 + integrations/slack/src/blocks.ts | 861 ++++++++++++++++++ integrations/slack/src/events.ts | 37 + integrations/slack/src/index.ts | 97 ++ integrations/slack/src/interactivity.ts | 215 +++++ integrations/slack/src/internal.ts | 26 + integrations/slack/src/internal/requests.ts | 475 ++++++++++ integrations/slack/src/schemas.ts | 119 +++ integrations/slack/tsconfig.json | 20 + integrations/slack/tsup.config.ts | 23 + packages/integration-sdk/src/types.ts | 2 + pnpm-lock.yaml | 39 +- 29 files changed, 2018 insertions(+), 32 deletions(-) rename integrations/github/src/{ => internal}/webhooks.ts (99%) create mode 100644 integrations/slack/README.md create mode 100644 integrations/slack/manifest.json create mode 100644 integrations/slack/package.json create mode 100644 integrations/slack/src/blocks.ts create mode 100644 integrations/slack/src/events.ts create mode 100644 integrations/slack/src/index.ts create mode 100644 integrations/slack/src/interactivity.ts create mode 100644 integrations/slack/src/internal.ts create mode 100644 integrations/slack/src/internal/requests.ts create mode 100644 integrations/slack/src/schemas.ts create mode 100644 integrations/slack/tsconfig.json create mode 100644 integrations/slack/tsup.config.ts diff --git a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts index b59b99f75..5d3c1f5d0 100644 --- a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts +++ b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts @@ -4,7 +4,8 @@ import type { NormalizedResponse, PerformedRequestResponse, } from "@trigger.dev/integration-sdk"; -import { resend, shopify, slack } from "internal-integrations"; +import { resend, shopify } from "internal-integrations"; +import * as slack from "@trigger.dev/slack/internal"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import type { IntegrationRequest } from "~/models/integrationRequest.server"; diff --git a/apps/webapp/app/services/slack/handleInteractivity.server.ts b/apps/webapp/app/services/slack/handleInteractivity.server.ts index 1d3b55c38..2d4a70cb9 100644 --- a/apps/webapp/app/services/slack/handleInteractivity.server.ts +++ b/apps/webapp/app/services/slack/handleInteractivity.server.ts @@ -1,4 +1,4 @@ -import { slack } from "@trigger.dev/providers"; +import { schemas } from "@trigger.dev/slack/internal"; import { ulid } from "ulid"; import { generateErrorMessage } from "zod-error"; import type { PrismaClient } from "~/db.server"; @@ -15,7 +15,7 @@ export class HandleSlackInteractivity { public async call(payload: unknown) { console.log("payload", JSON.stringify(payload, null, 2)); - const parsedPayload = slack.schemas.blockAction.safeParse(payload); + const parsedPayload = schemas.blockAction.safeParse(payload); if (!parsedPayload.success) { console.error( diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js index 1bf20b954..f2026a32c 100644 --- a/apps/webapp/remix.config.js +++ b/apps/webapp/remix.config.js @@ -15,6 +15,7 @@ module.exports = { "internal-bridge", "@trigger.dev/providers", "@trigger.dev/github", + "@trigger.dev/slack", "@trigger.dev/common-schemas", "@trigger.dev/sdk", "@trigger.dev/integrations", diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index b9d37884b..4333afa14 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -51,7 +51,9 @@ "../../packages/integration-sdk/src/*" ], "@trigger.dev/github": ["../../integrations/github/src/index"], - "@trigger.dev/github/*": ["../../integrations/github/src/*"] + "@trigger.dev/github/*": ["../../integrations/github/src/*"], + "@trigger.dev/slack": ["../../integrations/slack/src/index"], + "@trigger.dev/slack/*": ["../../integrations/slack/src/*"] }, "noEmit": true } diff --git a/config-packages/tsconfig/examples.json b/config-packages/tsconfig/examples.json index 48479fab9..4481a47da 100644 --- a/config-packages/tsconfig/examples.json +++ b/config-packages/tsconfig/examples.json @@ -35,6 +35,12 @@ ], "@trigger.dev/github/*": [ "../../integrations/github/src/*" + ], + "@trigger.dev/slack": [ + "../../integrations/slack/src/index" + ], + "@trigger.dev/slack/*": [ + "../../integrations/slack/src/*" ] } } diff --git a/examples/fetch-playground/package.json b/examples/fetch-playground/package.json index 92c2b3eb2..2bffd84ef 100644 --- a/examples/fetch-playground/package.json +++ b/examples/fetch-playground/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "A fetch playground for testing Trigger.dev functionality", "dependencies": { - "@trigger.dev/integrations": "workspace:*", + "@trigger.dev/slack": "workspace:*", "@trigger.dev/sdk": "workspace:*", "zod": "^3.20.2" }, diff --git a/examples/fetch-playground/src/index.ts b/examples/fetch-playground/src/index.ts index e1874064f..7fe765244 100644 --- a/examples/fetch-playground/src/index.ts +++ b/examples/fetch-playground/src/index.ts @@ -1,4 +1,4 @@ -import { slack } from "@trigger.dev/integrations"; +import * as slack from "@trigger.dev/slack"; import { Trigger, customEvent, webhookEvent } from "@trigger.dev/sdk"; import { z } from "zod"; diff --git a/examples/schedule-to-slack/package.json b/examples/schedule-to-slack/package.json index 16dfb7ad1..18fd2723a 100644 --- a/examples/schedule-to-slack/package.json +++ b/examples/schedule-to-slack/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "Send a message to slack on a schedule", "dependencies": { - "@trigger.dev/integrations": "workspace:*", + "@trigger.dev/slack": "workspace:*", "@trigger.dev/sdk": "workspace:*", "zod": "^3.20.2" }, diff --git a/examples/schedule-to-slack/src/index.ts b/examples/schedule-to-slack/src/index.ts index 6ca2262e2..a53e30df9 100644 --- a/examples/schedule-to-slack/src/index.ts +++ b/examples/schedule-to-slack/src/index.ts @@ -1,5 +1,5 @@ import { Trigger, scheduleEvent } from "@trigger.dev/sdk"; -import { slack } from "@trigger.dev/integrations"; +import * as slack from "@trigger.dev/slack"; const trigger = new Trigger({ id: "schedule-to-slack-2", @@ -11,10 +11,10 @@ const trigger = new Trigger({ run: async (event, ctx) => { await ctx.logger.info("It's me, the annoying slack bot!"); - // const response = await slack.postMessage("slaaaaaack", { - // channelName: "test-integrations", - // text: `Hello, the time is ${event.scheduledTime}, and I was last run at ${event.lastRunAt}!`, - // }); + const response = await slack.postMessage("slaaaaaack", { + channelName: "test-integrations", + text: `Hello, the time is ${event.scheduledTime}, and I was last run at ${event.lastRunAt}!`, + }); return event; }, diff --git a/examples/send-to-slack/package.json b/examples/send-to-slack/package.json index 1f261d4ee..daf53be7c 100644 --- a/examples/send-to-slack/package.json +++ b/examples/send-to-slack/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "description": "Send a message to slack when a customer creates a new custom domain", "dependencies": { - "@trigger.dev/integrations": "workspace:*", + "@trigger.dev/slack": "workspace:*", "@trigger.dev/sdk": "workspace:*", "jsx-slack": "^5.3.0", "zod": "^3.20.2" @@ -17,4 +17,4 @@ "scripts": { "dev": "tsx src/index.tsx" } -} +} \ No newline at end of file diff --git a/examples/send-to-slack/src/index.tsx b/examples/send-to-slack/src/index.tsx index 7953c4031..ec738577b 100644 --- a/examples/send-to-slack/src/index.tsx +++ b/examples/send-to-slack/src/index.tsx @@ -1,5 +1,5 @@ import { Trigger, customEvent, scheduleEvent } from "@trigger.dev/sdk"; -import { slack } from "@trigger.dev/integrations"; +import * as slack from "@trigger.dev/slack"; import JSXSlack, { Actions, Blocks, diff --git a/examples/smoke-test/package.json b/examples/smoke-test/package.json index 8f6881e9b..cb792adfd 100644 --- a/examples/smoke-test/package.json +++ b/examples/smoke-test/package.json @@ -4,7 +4,6 @@ "version": "0.0.1", "description": "Very basic smoke test for @trigger.dev/sdk", "dependencies": { - "@trigger.dev/integrations": "workspace:*", "@trigger.dev/sdk": "workspace:*", "ulid": "^2.3.0", "zod": "^3.20.2" @@ -17,4 +16,4 @@ "scripts": { "dev": "tsx src/index.ts" } -} +} \ No newline at end of file diff --git a/integrations/github/src/internal.ts b/integrations/github/src/internal.ts index 02e61a233..a4f3d91e9 100644 --- a/integrations/github/src/internal.ts +++ b/integrations/github/src/internal.ts @@ -1,7 +1,8 @@ -import { GitHubWebhookIntegration } from "./webhooks"; -import * as schemas from "./schemas"; +import { GitHubWebhookIntegration } from "./internal/webhooks"; -export const provider = { +export const webhooks = new GitHubWebhookIntegration(); + +export const metadata = { name: "GitHub", slug: "github", icon: "/integrations/github.png", @@ -10,7 +11,6 @@ export const provider = { type: "oauth", scopes: ["repo"], }, - schemas, }; -export const webhooks = new GitHubWebhookIntegration(); +export * as schemas from "./schemas"; diff --git a/integrations/github/src/webhooks.ts b/integrations/github/src/internal/webhooks.ts similarity index 99% rename from integrations/github/src/webhooks.ts rename to integrations/github/src/internal/webhooks.ts index 2277ce49c..24dde60e1 100644 --- a/integrations/github/src/webhooks.ts +++ b/integrations/github/src/internal/webhooks.ts @@ -12,7 +12,7 @@ import { WebhookOrganizationSourceSchema, WebhookRepoSourceSchema, WebhookSourceSchema, -} from "./schemas"; +} from "../schemas"; export class GitHubWebhookIntegration implements WebhookIntegration { keyForSource(source: unknown): string { diff --git a/integrations/github/tsconfig.json b/integrations/github/tsconfig.json index 8437d79f4..e159cec9c 100644 --- a/integrations/github/tsconfig.json +++ b/integrations/github/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@trigger.dev/tsconfig/node16.json", - "include": ["./src/**/*.ts"], + "include": ["./src/**/*.ts", "tsup.config.ts"], "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/integrations/slack/README.md b/integrations/slack/README.md new file mode 100644 index 000000000..1ad0e1497 --- /dev/null +++ b/integrations/slack/README.md @@ -0,0 +1,3 @@ +# Trigger.dev GitHub integration + +View more documentation [here](https://docs.trigger.dev) diff --git a/integrations/slack/manifest.json b/integrations/slack/manifest.json new file mode 100644 index 000000000..2f63a247e --- /dev/null +++ b/integrations/slack/manifest.json @@ -0,0 +1,35 @@ +{ + "display_information": { + "name": "Trigger.dev" + }, + "features": { + "bot_user": { + "display_name": "Trigger.dev", + "always_online": false + } + }, + "oauth_config": { + "redirect_urls": [ + "https://auth.trigger.dev/oauth/callback" + ], + "scopes": { + "user": [ + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "chat:write", + "reactions:write" + ], + "bot": [ + "chat:write", + "reactions:write" + ] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} \ No newline at end of file diff --git a/integrations/slack/package.json b/integrations/slack/package.json new file mode 100644 index 000000000..d07c09b9d --- /dev/null +++ b/integrations/slack/package.json @@ -0,0 +1,41 @@ +{ + "name": "@trigger.dev/slack", + "version": "0.1.16", + "description": "The official Slack integration for Trigger.dev", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "devDependencies": { + "@trigger.dev/integration-sdk": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "@trigger.dev/tsconfig": "workspace:*", + "@types/debug": "^4.1.7", + "@types/node": "16", + "rimraf": "^3.0.2", + "tsup": "^6.5.0" + }, + "peerDependencies": { + "@trigger.dev/sdk": "workspace:*" + }, + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && npm run build:tsup", + "build:tsup": "tsup" + }, + "dependencies": { + "@octokit/webhooks": "^10.4.0", + "debug": "^4.3.4", + "zod": "^3.20.2" + } +} \ No newline at end of file diff --git a/integrations/slack/src/blocks.ts b/integrations/slack/src/blocks.ts new file mode 100644 index 000000000..514b9725d --- /dev/null +++ b/integrations/slack/src/blocks.ts @@ -0,0 +1,861 @@ +import { z } from "zod"; + +export const imageElementSchema = z.object({ + type: z.literal("image"), + image_url: z.string(), + alt_text: z.string(), +}); + +export const plainTextElementSchema = z.object({ + type: z.literal("plain_text"), + text: z.string(), + emoji: z.boolean().optional(), +}); + +export const mrkdwnElementSchema = z.object({ + type: z.literal("mrkdwn"), + text: z.string(), + verbatim: z.boolean().optional(), +}); + +export const mrkdwnOptionSchema = z.object({ + text: mrkdwnElementSchema, + value: z.string().optional(), + url: z.string().optional(), + description: plainTextElementSchema.optional(), +}); + +export const plainTextOptionSchema = z.object({ + text: plainTextElementSchema, + value: z.string().optional(), + url: z.string().optional(), + description: plainTextElementSchema.optional(), +}); + +export const optionSchema = z.union([ + mrkdwnOptionSchema, + plainTextOptionSchema, +]); + +export const confirmSchema = z.object({ + title: plainTextElementSchema.optional(), + text: z.discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, + ]), + confirm: plainTextElementSchema.optional(), + deny: plainTextElementSchema.optional(), + style: z.union([z.literal("primary"), z.literal("danger")]).optional(), +}); + +/** + * @description Determines when an input element will return a + * {@link https://api.slack.com/reference/interaction-payloads/block-actions `block_actions` interaction payload}. + */ +export const dispatchActionConfigSchema = z.object({ + /** + * @description An array of interaction types that you would like to receive a + * {@link https://api.slack.com/reference/interaction-payloads/block-actions `block_actions` payload} for. Should be + * one or both of: + * `on_enter_pressed` — payload is dispatched when user presses the enter key while the input is in focus. Hint + * text will appear underneath the input explaining to the user to press enter to submit. + * `on_character_entered` — payload is dispatched when a character is entered (or removed) in the input. + */ + trigger_actions_on: z + .array( + z.union([ + z.literal("on_enter_pressed"), + z.literal("on_character_entered"), + ]) + ) + .optional(), +}); + +export const actionSchema = z.object({ + type: z.string(), + /** + * @description: An identifier for this action. You can use this when you receive an interaction payload to + * {@link https://api.slack.com/interactivity/hmergeling#payloads identify the source of the action}. Should be unique + * among all other `action_id`s in the containing block. Maximum length for this field is 255 characters. + */ + action_id: z.string().optional(), +}); + +export const actionIdSchema = z.object({ + /** + * @description: An identifier for this action. You can use this when you receive an interaction payload to + * {@link https://api.slack.com/interactivity/hmergeling#payloads identify the source of the action}. Should be unique + * among all other `action_id`s in the containing block. Maximum length for this field is 255 characters. + */ + action_id: z.string().optional(), +}); + +export const confirmableSchema = z.object({ + /** + * @description A {@see Confirm} object that defines an optional confirmation dialog after the element is interacted + * with. + */ + confirm: confirmSchema.optional(), +}); + +export const focusableSchema = z.object({ + /** + * @description Indicates whether the element will be set to auto focus within the + * {@link https://api.slack.com/reference/surfaces/views `view` object}. Only one element can be set to `true`. + * Defaults to `false`. + */ + focus_on_load: z.boolean().optional(), +}); + +export const placeholdableSchema = z.object({ + /** + * @description A {@see PlainTextElement} object that defines the placeholder text shown on the element. Maximum + * length for the `text` field in this object is 150 characters. + */ + placeholder: plainTextElementSchema.optional(), +}); + +export const dispatchableSchema = z.object({ + /** + * @description A {@see DispatchActionConfig} object that determines when during text input the element returns a + * {@link https://api.slack.com/reference/interaction-payloads/block-actions `block_actions` payload}. + */ + dispatch_action_config: dispatchActionConfigSchema.optional(), +}); + +export const usersSelectSchema = z + .object({ + type: z.literal("users_select"), + initial_user: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const multiUsersSelectSchema = z + .object({ + type: z.literal("multi_users_select"), + initial_users: z.array(z.string()).optional(), + max_selected_items: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const staticSelectSchema = z + .object({ + type: z.literal("static_select"), + initial_option: plainTextOptionSchema.optional(), + options: z.array(plainTextOptionSchema).optional(), + option_groups: z + .array( + z.object({ + label: plainTextElementSchema, + options: z.array(plainTextOptionSchema), + }) + ) + .optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const multiStaticSelectSchema = z + .object({ + type: z.literal("multi_static_select"), + initial_options: z.array(plainTextOptionSchema).optional(), + options: z.array(plainTextOptionSchema).optional(), + option_groups: z + .array( + z.object({ + label: plainTextElementSchema, + options: z.array(plainTextOptionSchema), + }) + ) + .optional(), + max_selected_items: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const conversationsSelectSchema = z + .object({ + type: z.literal("conversations_select"), + initial_conversation: z.string().optional(), + response_url_enabled: z.boolean().optional(), + default_to_current_conversation: z.boolean().optional(), + filter: z + .object({ + include: z + .array( + z.union([ + z.literal("im"), + z.literal("mpim"), + z.literal("private"), + z.literal("public"), + ]) + ) + .optional(), + exclude_external_shared_channels: z.boolean().optional(), + exclude_bot_users: z.boolean().optional(), + }) + .optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const multiConversationsSelectSchema = z + .object({ + type: z.literal("multi_conversations_select"), + initial_conversations: z.array(z.string()).optional(), + max_selected_items: z.number().optional(), + default_to_current_conversation: z.boolean().optional(), + filter: z + .object({ + include: z + .array( + z.union([ + z.literal("im"), + z.literal("mpim"), + z.literal("private"), + z.literal("public"), + ]) + ) + .optional(), + exclude_external_shared_channels: z.boolean().optional(), + exclude_bot_users: z.boolean().optional(), + }) + .optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const channelsSelectSchema = z + .object({ + type: z.literal("channels_select"), + initial_channel: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const multiChannelsSelectSchema = z + .object({ + type: z.literal("multi_channels_select"), + initial_channels: z.array(z.string()).optional(), + max_selected_items: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const externalSelectSchema = z + .object({ + type: z.literal("external_select"), + initial_option: plainTextOptionSchema.optional(), + min_query_length: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const multiExternalSelectSchema = z + .object({ + type: z.literal("multi_external_select"), + initial_options: z.array(plainTextOptionSchema).optional(), + min_query_length: z.number().optional(), + max_selected_items: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const buttonSchema = z + .object({ + type: z.literal("button"), + text: plainTextElementSchema, + value: z.string().optional(), + url: z.string().optional(), + style: z.union([z.literal("danger"), z.literal("primary")]).optional(), + accessibility_label: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema); + +export const overflowSchema = z + .object({ + type: z.literal("overflow"), + options: z.array(plainTextOptionSchema), + }) + .merge(actionIdSchema) + .merge(confirmableSchema); + +export const datepickerSchema = z + .object({ + type: z.literal("datepicker"), + initial_date: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const timepickerSchema = z + .object({ + type: z.literal("timepicker"), + initial_time: z.string().optional(), + timezone: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const radioButtonsSchema = z + .object({ + type: z.literal("radio_buttons"), + initial_option: optionSchema.optional(), + options: z.array(optionSchema), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema); + +/** + * @description An element that allows the selection of a time of day formatted as a UNIX timestamp. On desktop + * clients, this time picker will take the form of a dropdown list merge the date picker will take the form of a dropdown + * calendar. Both options will have free-text entry for precise choices. On mobile clients, the time picker merge date + * picker will use native UIs. + * {@link https://api.slack.com/reference/block-kit/block-elements#datetimepicker} + */ +export const dateTimepickerSchema = z + .object({ + type: z.literal("datetimepicker"), + /** + * @description The initial date merge time that is selected when the element is loaded, represented as a UNIX + * timestamp in seconds. This should be in the format of 10 digits, for example 1628633820 represents the date merge + * time August 10th, 2021 at 03:17pm PST. + */ + initial_date_time: z.number().optional(), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema); + +export const checkboxesSchema = z + .object({ + type: z.literal("checkboxes"), + initial_options: z.array(optionSchema).optional(), + options: z.array(optionSchema), + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema); + +export const plainTextInputSchema = z + .object({ + type: z.literal("plain_text_input"), + initial_value: z.string().optional(), + multiline: z.boolean().optional(), + min_length: z.number().optional(), + max_length: z.number().optional(), + dispatch_action_config: dispatchActionConfigSchema.optional(), + focus_on_load: z.boolean().optional(), + }) + .merge(actionIdSchema) + .merge(dispatchableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +/** + * @description A URL input element, similar to the {@see PlainTextInput} element, creates a single line field where + * a user can enter URL-encoded data. + * {@link https://api.slack.com/reference/block-kit/block-elements#url} + */ +export const uRLInputSchema = z + .object({ + type: z.literal("url_text_input"), + /** + * @description The initial value in the URL input when it is loaded. + */ + initial_value: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(dispatchableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +/** + * @description An email input element, similar to the {@see PlainTextInput} element, creates a single line field where + * a user can enter an email address. + * {@link https://api.slack.com/reference/block-kit/block-elements#email} + */ +export const emailInputSchema = z + .object({ + type: z.literal("email_text_input"), + /** + * @description The initial value in the email input when it is loaded. + */ + initial_value: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(dispatchableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +/** + * @description A number input element, similar to the {@see PlainTextInput} element, creates a single line field where + * a user can a number. This input elements accepts floating point numbers, for example, 0.25, 5.5, merge -10 are all + * valid input values. Decimal numbers are only allowed when `is_decimal_allowed` is equal to `true`. + * {@link https://api.slack.com/reference/block-kit/block-elements#number} + */ +export const numberInputSchema = z + .object({ + type: z.literal("number_input"), + /** + * @description Decimal numbers are allowed if this property is `true`, set the value to `false` otherwise. + */ + is_decimal_allowed: z.boolean(), + /** + * @description The initial value in the input when it is loaded. + */ + initial_value: z.string().optional(), + /** + * @description The minimum value, cannot be greater than `max_value`. + */ + min_value: z.string().optional(), + /** + * @description The maximum value, cannot be less than `min_value`. + */ + max_value: z.string().optional(), + }) + .merge(actionIdSchema) + .merge(dispatchableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const blockSchema = z.object({ + type: z.string(), + block_id: z.string().optional(), +}); + +export const imageBlockSchema = blockSchema.extend({ + type: z.literal("image"), + image_url: z.string(), + alt_text: z.string(), + title: plainTextElementSchema.optional(), +}); + +export const contextBlockSchema = blockSchema.extend({ + type: z.literal("context"), + elements: z.array( + z.discriminatedUnion("type", [ + imageElementSchema, + plainTextElementSchema, + mrkdwnElementSchema, + ]) + ), +}); + +export const dividerBlockSchema = blockSchema.extend({ + type: z.literal("divider"), +}); + +export const fileBlockSchema = blockSchema.extend({ + type: z.literal("file"), + source: z.string(), + external_id: z.string(), +}); + +export const headerBlockSchema = blockSchema.extend({ + type: z.literal("header"), + text: plainTextElementSchema, +}); + +export const messageMetadataEventPayloadObjectSchema = z.record( + z.union([z.string(), z.number(), z.boolean()]) +); + +export const messageAttachmentPreviewSchema = z.object({ + type: z.string().optional(), + can_remove: z.boolean().optional(), + title: plainTextElementSchema.optional(), + subtitle: plainTextElementSchema.optional(), + iconUrl: z.string().optional(), +}); + +export const optionFieldSchema = z.object({ + description: z.string().optional(), + text: z.string(), + value: z.string(), +}); + +export const confirmationSchema = z.object({ + dismiss_text: z.string().optional(), + ok_text: z.string().optional(), + text: z.string(), + title: z.string().optional(), +}); + +export const selectOptionSchema = z.object({ + label: z.string(), + value: z.string(), +}); + +export const callUserSlackSchema = z.object({ + slack_id: z.string(), +}); + +export const callUserExternalSchema = z.object({ + external_id: z.string(), + display_name: z.string(), + avatar_url: z.string(), +}); + +export const videoBlockSchema = blockSchema.extend({ + type: z.literal("video"), + video_url: z.string(), + thumbnail_url: z.string(), + alt_text: z.string(), + title: plainTextElementSchema, + title_url: z.string().optional(), + author_name: z.string().optional(), + provider_name: z.string().optional(), + provider_icon_url: z.string().optional(), + description: plainTextElementSchema.optional(), +}); + +export const dialogSchema = z.object({ + title: z.string(), + callback_id: z.string(), + elements: z.array( + z.object({ + type: z.union([ + z.literal("text"), + z.literal("textarea"), + z.literal("select"), + ]), + name: z.string(), + label: z.string(), + optional: z.boolean().optional(), + placeholder: z.string().optional(), + value: z.string().optional(), + max_length: z.number().optional(), + min_length: z.number().optional(), + hint: z.string().optional(), + subtype: z + .union([ + z.literal("email"), + z.literal("number"), + z.literal("tel"), + z.literal("url"), + ]) + .optional(), + data_source: z + .union([ + z.literal("users"), + z.literal("channels"), + z.literal("conversations"), + z.literal("external"), + ]) + .optional(), + selected_options: z.array(selectOptionSchema).optional(), + options: z.array(selectOptionSchema).optional(), + option_groups: z + .array( + z.object({ + label: z.string(), + options: z.array(selectOptionSchema), + }) + ) + .optional(), + min_query_length: z.number().optional(), + }) + ), + submit_label: z.string().optional(), + notify_on_cancel: z.boolean().optional(), + state: z.string().optional(), +}); + +const selectSchemas = [ + usersSelectSchema, + staticSelectSchema, + conversationsSelectSchema, + channelsSelectSchema, + externalSelectSchema, +]; + +export const selectSchema = z.discriminatedUnion("type", [ + usersSelectSchema, + staticSelectSchema, + conversationsSelectSchema, + channelsSelectSchema, + externalSelectSchema, +]); + +const multiSelectSchemas = [ + multiUsersSelectSchema, + multiStaticSelectSchema, + multiConversationsSelectSchema, + multiChannelsSelectSchema, + multiExternalSelectSchema, +]; +export const multiSelectSchema = z.discriminatedUnion("type", [ + multiUsersSelectSchema, + multiStaticSelectSchema, + multiConversationsSelectSchema, + multiChannelsSelectSchema, + multiExternalSelectSchema, +]); + +export const actionsBlockSchema = blockSchema.extend({ + type: z.literal("actions"), + elements: z.array( + z.discriminatedUnion("type", [ + buttonSchema, + overflowSchema, + datepickerSchema, + timepickerSchema, + dateTimepickerSchema, + ...selectSchemas, + radioButtonsSchema, + checkboxesSchema, + ]) + ), +}); + +export const sectionBlockSchema = blockSchema.extend({ + type: z.literal("section"), + text: z + .discriminatedUnion("type", [plainTextElementSchema, mrkdwnElementSchema]) + .optional(), + fields: z + .array( + z.discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, + ]) + ) + .optional(), + accessory: z + .discriminatedUnion("type", [ + buttonSchema, + overflowSchema, + datepickerSchema, + timepickerSchema, + ...selectSchemas, + ...multiSelectSchemas, + imageElementSchema, + radioButtonsSchema, + checkboxesSchema, + ]) + .optional(), +}); + +export const inputBlockSchema = blockSchema.extend({ + type: z.literal("input"), + label: plainTextElementSchema, + hint: plainTextElementSchema.optional(), + optional: z.boolean().optional(), + element: z.discriminatedUnion("type", [ + datepickerSchema, + ...selectSchemas, + ...multiSelectSchemas, + timepickerSchema, + dateTimepickerSchema, + plainTextInputSchema, + uRLInputSchema, + emailInputSchema, + numberInputSchema, + radioButtonsSchema, + checkboxesSchema, + ]), + dispatch_action: z.boolean().optional(), +}); + +export const messageMetadataSchema = z.object({ + event_type: z.string(), + event_payload: z.record( + z.union([ + z.string(), + z.number(), + z.boolean(), + messageMetadataEventPayloadObjectSchema, + z.array(messageMetadataEventPayloadObjectSchema), + ]) + ), +}); + +export const attachmentActionSchema = z.object({ + id: z.string().optional(), + confirm: confirmationSchema.optional(), + data_source: z + .union([ + z.literal("static"), + z.literal("channels"), + z.literal("conversations"), + z.literal("users"), + z.literal("external"), + ]) + .optional(), + min_query_length: z.number().optional(), + name: z.string().optional(), + options: z.array(optionFieldSchema).optional(), + option_groups: z + .array( + z.object({ + text: z.string(), + options: z.array(optionFieldSchema), + }) + ) + .optional(), + selected_options: z.array(optionFieldSchema).optional(), + style: z + .union([z.literal("default"), z.literal("primary"), z.literal("danger")]) + .optional(), + text: z.string(), + type: z.union([z.literal("button"), z.literal("select")]), + value: z.string().optional(), + url: z.string().optional(), +}); + +export const callUserSchema = z.union([ + callUserSlackSchema, + callUserExternalSchema, +]); + +const knownBlocks = [ + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, +]; + +export const knownBlockSchema = z.discriminatedUnion("type", [ + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, +]); + +const anyBlockSchema = z.discriminatedUnion("type", [ + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, +]); + +export const messageAttachmentSchema = z.object({ + blocks: z.array(anyBlockSchema).optional(), + fallback: z.string().optional(), + color: z + .union([ + z.literal("good"), + z.literal("warning"), + z.literal("danger"), + z.string(), + ]) + .optional(), + pretext: z.string().optional(), + author_name: z.string().optional(), + author_link: z.string().optional(), + author_icon: z.string().optional(), + title: z.string().optional(), + title_link: z.string().optional(), + text: z.string().optional(), + fields: z + .array( + z.object({ + title: z.string(), + value: z.string(), + short: z.boolean().optional(), + }) + ) + .optional(), + image_url: z.string().optional(), + thumb_url: z.string().optional(), + footer: z.string().optional(), + footer_icon: z.string().optional(), + ts: z.string().optional(), + actions: z.array(attachmentActionSchema).optional(), + callback_id: z.string().optional(), + mrkdwn_in: z + .array( + z.union([z.literal("pretext"), z.literal("text"), z.literal("fields")]) + ) + .optional(), + app_unfurl_url: z.string().optional(), + is_app_unfurl: z.boolean().optional(), + app_id: z.string().optional(), + bot_id: z.string().optional(), + preview: messageAttachmentPreviewSchema.optional(), +}); + +export const linkUnfurlsSchema = z.record(messageAttachmentSchema); + +export const homeViewSchema = z.object({ + type: z.literal("home"), + blocks: z.array(anyBlockSchema), + private_metadata: z.string().optional(), + callback_id: z.string().optional(), + external_id: z.string().optional(), +}); + +export const modalViewSchema = z.object({ + type: z.literal("modal"), + title: plainTextElementSchema, + blocks: z.array(anyBlockSchema), + close: plainTextElementSchema.optional(), + submit: plainTextElementSchema.optional(), + private_metadata: z.string().optional(), + callback_id: z.string().optional(), + clear_on_close: z.boolean().optional(), + notify_on_close: z.boolean().optional(), + external_id: z.string().optional(), +}); + +export const workflowStepViewSchema = z.object({ + type: z.literal("workflow_step"), + blocks: z.array(anyBlockSchema), + private_metadata: z.string().optional(), + callback_id: z.string().optional(), + submit_disabled: z.boolean().optional(), + external_id: z.string().optional(), +}); + +export const viewSchema: z.ZodDiscriminatedUnion< + "type", + [typeof homeViewSchema, typeof modalViewSchema, typeof workflowStepViewSchema] +> = z.discriminatedUnion("type", [ + homeViewSchema, + modalViewSchema, + workflowStepViewSchema, +]); diff --git a/integrations/slack/src/events.ts b/integrations/slack/src/events.ts new file mode 100644 index 000000000..50c847c38 --- /dev/null +++ b/integrations/slack/src/events.ts @@ -0,0 +1,37 @@ +import type { TriggerEvent } from "@trigger.dev/sdk"; +import { blockAction } from "./interactivity"; + +export function blockActionInteraction(params: { + blockId: string; + actionId?: string | string[]; +}): TriggerEvent { + const actionIds = + typeof params.actionId === "undefined" + ? [] + : Array.isArray(params.actionId) + ? params.actionId + : [params.actionId]; + + return { + metadata: { + type: "SLACK_INTERACTION", + service: "slack", + name: "block.action", + filter: { + service: ["slack"], + payload: { + actions: { + block_id: [params.blockId], + action_id: actionIds, + }, + }, + event: ["block.action"], + }, + source: { + blockId: params.blockId, + actionIds, + }, + }, + schema: blockAction, + }; +} diff --git a/integrations/slack/src/index.ts b/integrations/slack/src/index.ts new file mode 100644 index 000000000..15aa1c0ea --- /dev/null +++ b/integrations/slack/src/index.ts @@ -0,0 +1,97 @@ +import { getTriggerRun } from "@trigger.dev/sdk"; +import { z } from "zod"; +import * as events from "./events"; +import * as schemas from "./schemas"; + +export { events }; + +export type PostMessageOptions = z.infer< + typeof schemas.PostMessageOptionsSchema +>; + +export type PostMessageResponse = z.infer< + typeof schemas.PostMessageSuccessResponseSchema +>; + +export async function postMessage( + key: string, + message: PostMessageOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call postMessage outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "slack", + endpoint: "chat.postMessage", + params: message, + response: { + schema: schemas.PostMessageSuccessResponseSchema, + }, + }); + + return output; +} + +export type PostMessageResponseOptions = z.infer< + typeof schemas.PostMessageResponseOptionsSchema +>; + +export type PostMessageResponseResponse = z.infer< + typeof schemas.PostMessageResponseSuccessResponseSchema +>; + +export async function postMessageResponse( + key: string, + responseUrl: string, + message: PostMessageResponseOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call postMessageResponse outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "slack", + endpoint: "chat.postMessageResponse", + params: { message, responseUrl }, + response: { + schema: schemas.PostMessageResponseSuccessResponseSchema, + }, + }); + + return output; +} + +export type AddReactionOptions = z.infer< + typeof schemas.AddReactionOptionsSchema +>; + +export type AddReactionResponse = z.infer< + typeof schemas.AddReactionSuccessResponseSchema +>; + +export async function addReaction( + key: string, + options: AddReactionOptions +): Promise { + const run = getTriggerRun(); + + if (!run) { + throw new Error("Cannot call addReaction outside of a trigger run"); + } + + const output = await run.performRequest(key, { + service: "slack", + endpoint: "reactions.add", + params: options, + response: { + schema: schemas.AddReactionSuccessResponseSchema, + }, + }); + + return output; +} diff --git a/integrations/slack/src/interactivity.ts b/integrations/slack/src/interactivity.ts new file mode 100644 index 000000000..2e341d605 --- /dev/null +++ b/integrations/slack/src/interactivity.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; +import { + knownBlockSchema, + mrkdwnElementSchema, + optionFieldSchema, + plainTextElementSchema, + viewSchema, +} from "./blocks"; + +const textSchema = z.discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, +]); +const blockActionType = z.union([ + z.literal("block_actions"), + z.literal("interactive_message"), +]); + +const sourceType = z.literal("message"); + +const commonActionSchema = z.object({ + action_id: z.string(), + block_id: z.string(), + action_ts: z.string(), +}); + +const buttonAction = z.object({ + type: z.literal("button"), + text: textSchema.optional(), + value: z.string(), +}); + +const selectedOptionSchema = z.object({ + text: z.object({ + type: z.string(), + text: z.string(), + emoji: z.boolean().optional(), + }), + value: z.string(), +}); +const placeholderSchema = z.object({ + type: z.string(), + text: z.string(), + emoji: z.boolean(), +}); +const staticSelectAction = z.object({ + type: z.literal("static_select"), + selected_option: selectedOptionSchema.nullable(), + placeholder: placeholderSchema.optional(), +}); + +const userSelectAction = z.object({ + type: z.literal("users_select"), + selected_user: z.string().nullable(), + initial_user: z.string().optional(), +}); + +const conversationsSelectAction = z.object({ + type: z.literal("conversations_select"), + selected_conversation: z.string().nullable(), + initial_conversation: z.string().optional(), +}); +const channelSelectAction = z.object({ + type: z.literal("channels_select"), + selected_channel: z.string().nullable(), + initial_channel: z.string().optional(), +}); + +const datePickerAction = z.object({ + type: z.literal("datepicker"), + selected_date: z.string().nullable(), + initial_date: z.string().optional(), +}); + +const checkboxesAction = z.object({ + type: z.literal("checkboxes"), + selected_options: z.array(optionFieldSchema), +}); + +const radioButtonsSchema = z.object({ + type: z.literal("radio_buttons"), + selectedOption: optionFieldSchema, +}); + +const timePickerSchema = z.object({ + type: z.literal("timepicker"), + selected_time: z.string().nullable(), + initial_time: z.string().optional(), +}); + +const plainTextInputSchema = z.object({ + type: z.literal("plain_text_input"), + value: z.string().nullable(), + initial_value: z.string().optional(), +}); + +const multiUsersSelectSchema = z.object({ + type: z.literal("multi_users_select"), + selected_users: z.array(z.string()), + initial_users: z.array(z.string()).optional(), +}); + +const multiStaticSelectSchema = z.object({ + type: z.literal("multi_static_select"), + selected_options: z.array(selectedOptionSchema), + placeholder: placeholderSchema.optional(), +}); + +const possibleActionsSchema = z.discriminatedUnion("type", [ + buttonAction, + staticSelectAction, + userSelectAction, + conversationsSelectAction, + channelSelectAction, + datePickerAction, + checkboxesAction, + radioButtonsSchema, + timePickerSchema, + plainTextInputSchema, + multiUsersSelectSchema, + multiStaticSelectSchema, +]); + +const actionSchema = possibleActionsSchema.and(commonActionSchema); + +//state.values.issue.action.block.rating.selected_option +const stateSchema = z.object({ + values: z.record(z.record(possibleActionsSchema)), +}); + +const userSchema = z.object({ + id: z.string(), + username: z.string(), + name: z.string(), + team_id: z.string(), +}); + +const containerSchema = z.object({ + type: sourceType, + message_ts: z.string(), + channel_id: z.string(), + is_ephemeral: z.boolean(), +}); + +const teamSchema = z.object({ id: z.string(), domain: z.string() }); +const channelSchema = z.object({ id: z.string(), name: z.string() }); + +const viewActionDataSchema = z.object({ + id: z.string(), + team_id: z.string(), + state: z + .object({ + values: z.record(z.record(z.any())), + }) + .optional(), + hash: z.string(), + previous_view_id: z.string().optional(), + root_view_id: z.string().optional(), + app_id: z.string().optional(), + app_installed_team_id: z.string().optional(), + bot_id: z.string().optional(), +}); +const viewActionSchema: Zod.ZodIntersection< + typeof viewSchema, + typeof viewActionDataSchema +> = viewSchema.and(viewActionDataSchema); + +const messageActionSchema = z.object({ + bot_id: z.string(), + type: sourceType, + text: z.string().optional(), + user: z.string().optional(), + ts: z.string(), + app_id: z.string().optional(), + blocks: z.array(knownBlockSchema).optional(), + team: z.string().optional(), + metadata: z + .object({ + event_type: z.string(), + event_payload: z.object({ requestId: z.string() }), + }) + .optional(), +}); + +export const blockAction: Zod.ZodObject<{ + type: typeof blockActionType; + user: typeof userSchema; + api_app_id: Zod.ZodString; + container: typeof containerSchema; + trigger_id: z.ZodOptional; + team: typeof teamSchema; + enterprise: Zod.ZodAny; + is_enterprise_install: Zod.ZodBoolean; + channel: typeof channelSchema; + view: z.ZodOptional; + message: z.ZodOptional; + state: z.ZodOptional; + response_url: Zod.ZodString; + actions: Zod.ZodArray; +}> = z.object({ + type: blockActionType, + user: userSchema, + api_app_id: z.string(), + container: containerSchema, + trigger_id: z.string().optional(), + team: teamSchema, + enterprise: z.any(), + is_enterprise_install: z.boolean(), + channel: channelSchema, + view: viewActionSchema.optional(), + message: messageActionSchema.optional(), + state: stateSchema.optional(), + response_url: z.string(), + actions: z.array(actionSchema), +}); diff --git a/integrations/slack/src/internal.ts b/integrations/slack/src/internal.ts new file mode 100644 index 000000000..ee2265711 --- /dev/null +++ b/integrations/slack/src/internal.ts @@ -0,0 +1,26 @@ +import { SlackRequestIntegration } from "./internal/requests"; + +export const requests = new SlackRequestIntegration(); + +export const metadata = { + name: "Slack", + slug: "slack", + icon: "/integrations/slack.png", + enabledFor: "all", + authentication: { + type: "oauth", + scopes: [ + "channels:read", + "channels:join", + "channels:manage", + "chat:write", + "groups:write", + "im:write", + "mpim:write", + "chat:write.customize", + "reactions:write", + ], + }, +}; + +export * as schemas from "./schemas"; diff --git a/integrations/slack/src/internal/requests.ts b/integrations/slack/src/internal/requests.ts new file mode 100644 index 000000000..457e8c7f2 --- /dev/null +++ b/integrations/slack/src/internal/requests.ts @@ -0,0 +1,475 @@ +import { HttpEndpoint, HttpService } from "@trigger.dev/integration-sdk"; +import type { + DisplayProperties, + CacheService, + PerformedRequestResponse, + PerformRequestOptions, + RequestIntegration, + AccessInfo, + ReactNode, +} from "@trigger.dev/integration-sdk"; +import debug from "debug"; +import { getAccessToken } from "@trigger.dev/integration-sdk"; +import { z } from "zod"; +import { + AddReactionOptionsSchema, + AddReactionResponseSchema, + ChannelNameOrIdSchema, + JoinConversationBodySchema, + JoinConversationResponseSchema, + ListConversationsResponseSchema, + PostMessageBodySchema, + PostMessageOptionsSchema, + PostMessageResponseOptionsSchema, + PostMessageResponseSchema, +} from "../schemas"; + +const log = debug("trigger:integrations:slack"); + +const SendSlackMessageRequestBodySchema = PostMessageBodySchema.extend({ + link_names: z.literal(1), + metadata: z + .object({ event_type: z.string(), event_payload: z.any() }) + .optional(), +}); + +export class SlackRequestIntegration implements RequestIntegration { + #joinChannelEndpoint = new HttpEndpoint< + typeof JoinConversationResponseSchema, + typeof JoinConversationBodySchema + >({ + response: JoinConversationResponseSchema, + method: "POST", + path: "/conversations.join", + }); + + #listConversationsEndpoint = new HttpEndpoint({ + response: ListConversationsResponseSchema, + method: "GET", + path: "/conversations.list", + }); + + #postMessageEndpoint = new HttpEndpoint< + typeof PostMessageResponseSchema, + typeof SendSlackMessageRequestBodySchema + >({ + response: PostMessageResponseSchema, + method: "POST", + path: "/chat.postMessage", + }); + + #addReactionEndpoint = new HttpEndpoint< + typeof AddReactionResponseSchema, + typeof AddReactionOptionsSchema + >({ + response: AddReactionResponseSchema, + method: "POST", + path: "/reactions.add", + }); + + constructor(private readonly baseUrl: string = "https://slack.com/api") {} + + perform(options: PerformRequestOptions): Promise { + switch (options.endpoint) { + case "chat.postMessage": { + return this.#postMessage( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "chat.postMessageResponse": { + return this.#postMessageResponse( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + case "reactions.add": { + return this.#addReaction( + options.accessInfo, + options.params, + options.cache, + options.metadata + ); + } + default: { + throw new Error(`Unknown endpoint: ${options.endpoint}`); + } + } + } + + displayProperties(endpoint: string, params: any): DisplayProperties { + switch (endpoint) { + case "chat.postMessage": { + return { + title: `Post message to ${ + "channelName" in params ? params.channelName : params.channelId + }`, + properties: [ + { + key: "Text", + value: params.text, + }, + ], + }; + } + case "chat.postMessageResponse": { + return { + title: `Post response`, + properties: [], + }; + } + case "reactions.add": { + return { + title: `Add reaction to message ${params.timestamp}`, + properties: [ + { + key: "Reaction", + value: params.name, + }, + ], + }; + } + + default: { + throw new Error(`Unknown endpoint: ${endpoint}`); + } + } + } + + renderComponent(input: any, output: any): ReactNode { + return null; + } + + async #postMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = PostMessageOptionsSchema.parse(params); + + log("chat.postMessage %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: this.baseUrl, + }); + + const channelId = await this.#findChannelId(service, params, cache); + + if (!channelId) { + return { + ok: false, + isRetryable: false, + response: { + output: { + message: `channelId not found`, + }, + context: { + statusCode: 404, + headers: {}, + }, + }, + }; + } + + log("found channelId %s", channelId); + + const response = await service.performRequest(this.#postMessageEndpoint, { + ...parsedParams, + link_names: 1, + channel: channelId, + metadata: metadata + ? { event_type: "post_message", event_payload: metadata } + : undefined, + }); + + if (!response.success) { + log("chat.postMessage failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + if (!response.data.ok && response.data.error === "not_in_channel") { + log( + "chat.postMessage failed with not_in_channel, attempting to join channel %s", + channelId + ); + + // Attempt to join the channel, and then retry the request + const joinResponse = await service.performRequest( + this.#joinChannelEndpoint, + { + channel: channelId, + } + ); + + if (joinResponse.success && joinResponse.data.ok) { + log("joined channel %s, retrying postMessage", channelId); + + return this.#postMessage(accessInfo, params); + } + } + + const ok = response.data.ok; + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("chat.postMessage performedRequest %O", performedRequest); + + return performedRequest; + } + + async #addReaction( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = AddReactionOptionsSchema.parse(params); + + log("reactions.add %O", parsedParams); + + const accessToken = getAccessToken(accessInfo); + + const service = new HttpService({ + accessToken, + baseUrl: this.baseUrl, + }); + + const channelId = await this.#findChannelId(service, parsedParams, cache); + + if (!channelId) { + return { + ok: false, + isRetryable: false, + response: { + output: { + message: `channelId not found`, + }, + context: { + statusCode: 404, + headers: {}, + }, + }, + }; + } + + log("found channelId %s", channelId); + + const response = await service.performRequest(this.#addReactionEndpoint, { + ...parsedParams, + // @ts-ignore + channel: channelId, + }); + + if (!response.success) { + log("reactions.add failed %O", response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.error, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + } + + if (!response.data.ok && response.data.error === "not_in_channel") { + log( + "reactions.add failed with not_in_channel, attempting to join channel %s", + channelId + ); + + // Attempt to join the channel, and then retry the request + const joinResponse = await service.performRequest( + this.#joinChannelEndpoint, + { + channel: channelId, + } + ); + + if (joinResponse.success && joinResponse.data.ok) { + log("joined channel %s, retrying reactions.add", channelId); + + return this.#addReaction(accessInfo, params); + } + } + + const ok = response.data.ok; + + const performedRequest = { + ok, + isRetryable: this.#isRetryable(response.statusCode), + response: { + output: response.data, + context: { + statusCode: response.statusCode, + headers: response.headers, + }, + }, + }; + + log("chat.postMessage performedRequest %O", performedRequest); + + return performedRequest; + } + + async #postMessageResponse( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = z + .object({ + message: PostMessageResponseOptionsSchema, + responseUrl: z.string(), + }) + .parse(params); + + log("chat.postMessageResponse %O", parsedParams); + + const response = await fetch(parsedParams.responseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...parsedParams.message, + metadata: metadata + ? { event_type: "post_message_response", event_payload: metadata } + : undefined, + }), + }); + + if (!response.ok) { + log("chat.postMessageResponse failed %O", response); + + const error = await safeGetJson(response); + + return { + ok: false, + isRetryable: this.#isRetryable(response.status), + response: { + output: error + ? error + : { name: `${response.status}`, message: response.statusText }, + context: { + statusCode: response.status, + headers: response.headers, + }, + }, + }; + } + + const output = await safeGetJson(response); + + const performedRequest = { + ok: response.ok, + isRetryable: this.#isRetryable(response.status), + response: { + output, + context: { + statusCode: response.status, + headers: response.headers, + }, + }, + }; + + log("chat.postMessage performedRequest %O", performedRequest); + + return performedRequest; + } + + #isRetryable(statusCode: number): boolean { + return ( + statusCode === 408 || + statusCode === 429 || + statusCode === 500 || + statusCode === 502 || + statusCode === 503 || + statusCode === 504 + ); + } + + // Will use the conversations.list API (using fetch) to find the channel ID + // unless the channel is already provided in the format of a channelID (for example: "D8572TUFR" or "C01BQJZLJGZ") + async #findChannelId( + service: HttpService, + params: z.infer, + cache?: CacheService + ): Promise { + if ("channelId" in params) { + return params.channelId; + } + + if (!("channelName" in params)) { + throw new Error("Invalid params, mising channelId and channelName"); + } + + //if the channelName starts with a #, remove it + if (params.channelName.startsWith("#")) { + params.channelName = params.channelName.substring(1); + } + + const cachedChannelId = await cache?.get(params.channelName); + + if (cachedChannelId) { + return cachedChannelId; + } + + const response = await service.performRequest( + this.#listConversationsEndpoint + ); + + if (response.success && response.data.ok) { + const { channels } = response.data; + + const channelInfo = channels.find( + (c: any) => c.name === params.channelName + ); + + if (channelInfo) { + await cache?.set(params.channelName, channelInfo.id, 60 * 60 * 24); + return channelInfo.id; + } + } + + return undefined; + } +} + +function safeGetJson(response: Response): Promise { + return response.json().catch(() => null); +} diff --git a/integrations/slack/src/schemas.ts b/integrations/slack/src/schemas.ts new file mode 100644 index 000000000..c1fe80518 --- /dev/null +++ b/integrations/slack/src/schemas.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import { knownBlockSchema } from "./blocks"; +import { blockAction } from "./interactivity"; + +export { blockAction }; + +export const PostMessageSuccessResponseSchema = z.object({ + ok: z.literal(true), + channel: z.string(), + ts: z.string(), + message: z.object({ + text: z.string(), + user: z.string().optional(), + bot_id: z.string(), + attachments: z.array(z.unknown()).optional(), + type: z.string(), + subtype: z.string().optional(), + ts: z.string(), + }), +}); + +export const ErrorResponseSchema = z.object({ + ok: z.literal(false), + error: z.string(), +}); + +export const PostMessageResponseSchema = z.discriminatedUnion("ok", [ + PostMessageSuccessResponseSchema, + ErrorResponseSchema, +]); + +export const PostMessageBodySchema = z.object({ + channel: z.string(), + text: z.string(), + blocks: z.array(knownBlockSchema).optional(), + username: z.string().optional(), + icon_emoji: z.string().optional(), + icon_url: z.string().optional(), +}); + +export const ChannelNameOrIdSchema = z.union([ + z.object({ channelId: z.string() }), + z.object({ channelName: z.string() }), +]); + +export const PostMessageOptionsSchema = z + .object({ + text: z.string(), + blocks: z.array(knownBlockSchema).optional(), + username: z.string().optional(), + icon_emoji: z.string().optional(), + icon_url: z.string().optional(), + }) + .and(ChannelNameOrIdSchema); + +export const AddReactionOptionsSchema = z + .object({ + name: z.string(), + timestamp: z.string(), + }) + .and(ChannelNameOrIdSchema); + +export const JoinConversationSuccessResponseSchema = z.object({ + ok: z.literal(true), + channel: z.object({ + id: z.string(), + }), +}); + +export const JoinConversationResponseSchema = z.discriminatedUnion("ok", [ + JoinConversationSuccessResponseSchema, + ErrorResponseSchema, +]); + +export const JoinConversationBodySchema = z.object({ + channel: z.string(), +}); + +export const ListConversationsSuccessResponseSchema = z.object({ + ok: z.literal(true), + channels: z.array( + z.object({ + id: z.string(), + name: z.string(), + }) + ), +}); + +export const ListConversationsResponseSchema = z.discriminatedUnion("ok", [ + ListConversationsSuccessResponseSchema, + ErrorResponseSchema, +]); + +export const PostMessageResponseOptionsSchema = z.object({ + text: z.string().optional(), + blocks: z.array(knownBlockSchema).optional(), + response_type: z.enum(["in_channel"]).optional(), + replace_original: z.boolean().optional(), + delete_original: z.boolean().optional(), + thread_ts: z.string().optional(), +}); + +export const PostMessageResponseSuccessResponseSchema = z.object({ + ok: z.literal(true), +}); + +export const PostMessageResponseResponseSchema = z.discriminatedUnion("ok", [ + PostMessageResponseSuccessResponseSchema, + ErrorResponseSchema, +]); + +export const AddReactionSuccessResponseSchema = z.object({ + ok: z.literal(true), +}); + +export const AddReactionResponseSchema = z.discriminatedUnion("ok", [ + AddReactionSuccessResponseSchema, + ErrorResponseSchema, +]); diff --git a/integrations/slack/tsconfig.json b/integrations/slack/tsconfig.json new file mode 100644 index 000000000..e159cec9c --- /dev/null +++ b/integrations/slack/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@trigger.dev/tsconfig/node16.json", + "include": ["./src/**/*.ts", "tsup.config.ts"], + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "paths": { + "@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"], + "@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"], + "@trigger.dev/integration-sdk/*": [ + "../../packages/integration-sdk/src/*" + ], + "@trigger.dev/integration-sdk": [ + "../../packages/integration-sdk/src/index" + ] + } + }, + "exclude": ["node_modules"] +} diff --git a/integrations/slack/tsup.config.ts b/integrations/slack/tsup.config.ts new file mode 100644 index 000000000..a71244deb --- /dev/null +++ b/integrations/slack/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + name: "main", + entry: ["./src/index.ts"], + outDir: "./dist", + platform: "node", + format: ["cjs"], + legacyOutput: true, + sourcemap: true, + clean: true, + bundle: true, + splitting: false, + dts: true, + treeshake: { + preset: "smallest", + }, + external: ["http", "https", "util", "events", "tty", "os", "timers"], + esbuildPlugins: [], + noExternal: ["@trigger.dev/common-schemas"], + }, +]); diff --git a/packages/integration-sdk/src/types.ts b/packages/integration-sdk/src/types.ts index 0bd63aa5b..7ae10ae73 100644 --- a/packages/integration-sdk/src/types.ts +++ b/packages/integration-sdk/src/types.ts @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +export type { ReactNode }; + export type AccessInfo = | { type: "oauth2"; accessToken: string } | { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05255538..608b74c3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -393,15 +393,15 @@ importers: examples/fetch-playground: specifiers: - '@trigger.dev/integrations': workspace:* '@trigger.dev/sdk': workspace:* + '@trigger.dev/slack': workspace:* '@trigger.dev/tsconfig': workspace:* '@types/node': '16' tsx: ^3.12.0 zod: ^3.20.2 dependencies: - '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/slack': link:../../integrations/slack zod: 3.20.2 devDependencies: '@trigger.dev/tsconfig': link:../../config-packages/tsconfig @@ -491,15 +491,15 @@ importers: examples/schedule-to-slack: specifiers: - '@trigger.dev/integrations': workspace:* '@trigger.dev/sdk': workspace:* + '@trigger.dev/slack': workspace:* '@trigger.dev/tsconfig': workspace:* '@types/node': '16' tsx: ^3.12.0 zod: ^3.20.2 dependencies: - '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/slack': link:../../integrations/slack zod: 3.20.2 devDependencies: '@trigger.dev/tsconfig': link:../../config-packages/tsconfig @@ -508,16 +508,16 @@ importers: examples/send-to-slack: specifiers: - '@trigger.dev/integrations': workspace:* '@trigger.dev/sdk': workspace:* + '@trigger.dev/slack': workspace:* '@trigger.dev/tsconfig': workspace:* '@types/node': '16' jsx-slack: ^5.3.0 tsx: ^3.12.0 zod: ^3.20.2 dependencies: - '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/slack': link:../../integrations/slack jsx-slack: 5.3.0 zod: 3.20.2 devDependencies: @@ -544,7 +544,6 @@ importers: examples/smoke-test: specifiers: - '@trigger.dev/integrations': workspace:* '@trigger.dev/sdk': workspace:* '@trigger.dev/tsconfig': workspace:* '@types/node': '16' @@ -552,7 +551,6 @@ importers: ulid: ^2.3.0 zod: ^3.20.2 dependencies: - '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/sdk': link:../../packages/trigger-sdk ulid: 2.3.0 zod: 3.20.2 @@ -582,6 +580,31 @@ importers: rimraf: 3.0.2 tsup: 6.5.0 + integrations/slack: + specifiers: + '@octokit/webhooks': ^10.4.0 + '@trigger.dev/integration-sdk': workspace:* + '@trigger.dev/sdk': workspace:* + '@trigger.dev/tsconfig': workspace:* + '@types/debug': ^4.1.7 + '@types/node': '16' + debug: ^4.3.4 + rimraf: ^3.0.2 + tsup: ^6.5.0 + zod: ^3.20.2 + dependencies: + '@octokit/webhooks': 10.5.1 + debug: 4.3.4 + zod: 3.20.2 + devDependencies: + '@trigger.dev/integration-sdk': link:../../packages/integration-sdk + '@trigger.dev/sdk': link:../../packages/trigger-sdk + '@trigger.dev/tsconfig': link:../../config-packages/tsconfig + '@types/debug': 4.1.7 + '@types/node': 16.18.11 + rimraf: 3.0.2 + tsup: 6.5.0 + packages/common-schemas: specifiers: '@trigger.dev/tsconfig': workspace:*