From 12dfc8dbe1f823372e19397595d0b8cbd833b671 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 16:40:10 -0800 Subject: [PATCH 01/39] =?UTF-8?q?Added=20scope=20that=20should=20allow=20p?= =?UTF-8?q?osting=20as=20a=20custom=20name=20=E2=80=9Cchat:write.customize?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/trigger-providers/src/providers/slack/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/trigger-providers/src/providers/slack/index.ts b/packages/trigger-providers/src/providers/slack/index.ts index 03461c695a3..e3149c0582c 100644 --- a/packages/trigger-providers/src/providers/slack/index.ts +++ b/packages/trigger-providers/src/providers/slack/index.ts @@ -15,6 +15,7 @@ export const slack = { "groups:write", "im:write", "mpim:write", + "chat:write.customize", ], }, schemas, From 809ffe2168e660b6c8c36b4882b0952e4ffea183 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 16:40:34 -0800 Subject: [PATCH 02/39] Added Slack blocks schemas --- .../src/providers/slack/blocks.ts | 775 ++++++++++++++++++ .../src/providers/slack/schemas.ts | 9 + 2 files changed, 784 insertions(+) create mode 100644 packages/trigger-providers/src/providers/slack/blocks.ts diff --git a/packages/trigger-providers/src/providers/slack/blocks.ts b/packages/trigger-providers/src/providers/slack/blocks.ts new file mode 100644 index 00000000000..a2ed9fed64e --- /dev/null +++ b/packages/trigger-providers/src/providers/slack/blocks.ts @@ -0,0 +1,775 @@ +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.union([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/handling#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 = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("users_select"), + initial_user: z.string().optional(), + }); + +export const multiUsersSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("multi_users_select"), + initial_users: z.array(z.string()).optional(), + max_selected_items: z.number().optional(), + }); + +export const staticSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +export const multiStaticSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +export const conversationsSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +export const multiConversationsSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +export const channelsSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("channels_select"), + initial_channel: z.string().optional(), + }); + +export const multiChannelsSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("multi_channels_select"), + initial_channels: z.array(z.string()).optional(), + max_selected_items: z.number().optional(), + }); + +export const externalSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("external_select"), + initial_option: plainTextOptionSchema.optional(), + min_query_length: z.number().optional(), + }); + +export const multiExternalSelectSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("multi_external_select"), + initial_options: z.array(plainTextOptionSchema).optional(), + min_query_length: z.number().optional(), + max_selected_items: z.number().optional(), + }); + +export const buttonSchema = actionSchema + .extend(confirmableSchema.shape) + .extend({ + 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(), + }); + +export const overflowSchema = actionSchema + .extend(confirmableSchema.shape) + .extend({ + type: z.literal("overflow"), + options: z.array(plainTextOptionSchema), + }); + +export const datepickerSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("datepicker"), + initial_date: z.string().optional(), + }); + +export const timepickerSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("timepicker"), + initial_time: z.string().optional(), + timezone: z.string().optional(), + }); + +export const radioButtonsSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend({ + type: z.literal("radio_buttons"), + initial_option: optionSchema.optional(), + options: z.array(optionSchema), + }); + +/** + * @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 and 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 and date + * picker will use native UIs. + * {@link https://api.slack.com/reference/block-kit/block-elements#datetimepicker} + */ +export const dateTimepickerSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend({ + type: z.literal("datetimepicker"), + /** + * @description The initial date and 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 and + * time August 10th, 2021 at 03:17pm PST. + */ + initial_date_time: z.number().optional(), + }); + +export const checkboxesSchema = actionSchema + .extend(confirmableSchema.shape) + .extend(focusableSchema.shape) + .extend({ + type: z.literal("checkboxes"), + initial_options: z.array(optionSchema).optional(), + options: z.array(optionSchema), + }); + +export const plainTextInputSchema = actionSchema + .extend(dispatchableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +/** + * @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 = actionSchema + .extend(dispatchableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("url_text_input"), + /** + * @description The initial value in the URL input when it is loaded. + */ + initial_value: z.string().optional(), + }); + +/** + * @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 = actionSchema + .extend(dispatchableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + type: z.literal("email_text_input"), + /** + * @description The initial value in the email input when it is loaded. + */ + initial_value: z.string().optional(), + }); + +/** + * @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, and -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 = actionSchema + .extend(dispatchableSchema.shape) + .extend(focusableSchema.shape) + .extend(placeholdableSchema.shape) + .extend({ + 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(), + }); + +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.union([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(), +}); + +export const selectSchema = z.union([ + usersSelectSchema, + staticSelectSchema, + conversationsSelectSchema, + channelsSelectSchema, + externalSelectSchema, +]); + +export const multiSelectSchema = z.union([ + multiUsersSelectSchema, + multiStaticSelectSchema, + multiConversationsSelectSchema, + multiChannelsSelectSchema, + multiExternalSelectSchema, +]); + +export const actionsBlockSchema = blockSchema.extend({ + type: z.literal("actions"), + elements: z.array( + z.union([ + buttonSchema, + overflowSchema, + datepickerSchema, + timepickerSchema, + dateTimepickerSchema, + selectSchema, + radioButtonsSchema, + checkboxesSchema, + actionSchema, + ]) + ), +}); + +export const sectionBlockSchema = blockSchema.extend({ + type: z.literal("section"), + text: z.union([plainTextElementSchema, mrkdwnElementSchema]).optional(), + fields: z + .array(z.union([plainTextElementSchema, mrkdwnElementSchema])) + .optional(), + accessory: z + .union([ + buttonSchema, + overflowSchema, + datepickerSchema, + timepickerSchema, + selectSchema, + multiSelectSchema, + actionSchema, + imageElementSchema, + radioButtonsSchema, + checkboxesSchema, + ]) + .optional(), +}); + +export const inputBlockSchema = blockSchema.extend({ + type: z.literal("input"), + label: plainTextElementSchema, + hint: plainTextElementSchema.optional(), + optional: z.boolean().optional(), + element: z.union([ + selectSchema, + multiSelectSchema, + datepickerSchema, + 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, +]); + +export const knownBlockSchema = z.union([ + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, +]); + +export const messageAttachmentSchema = z.object({ + blocks: z.array(z.union([knownBlockSchema, blockSchema])).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(z.union([knownBlockSchema, blockSchema])), + 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(z.union([knownBlockSchema, blockSchema])), + 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(z.union([knownBlockSchema, blockSchema])), + private_metadata: z.string().optional(), + callback_id: z.string().optional(), + submit_disabled: z.boolean().optional(), + external_id: z.string().optional(), +}); + +export const viewSchema: z.ZodUnion< + [typeof homeViewSchema, typeof modalViewSchema, typeof workflowStepViewSchema] +> = z.union([homeViewSchema, modalViewSchema, workflowStepViewSchema]); diff --git a/packages/trigger-providers/src/providers/slack/schemas.ts b/packages/trigger-providers/src/providers/slack/schemas.ts index 0a2d2427f0c..064cdb8b10d 100644 --- a/packages/trigger-providers/src/providers/slack/schemas.ts +++ b/packages/trigger-providers/src/providers/slack/schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { knownBlockSchema } from "./blocks"; export const PostMessageSuccessResponseSchema = z.object({ ok: z.literal(true), @@ -28,6 +29,10 @@ export const PostMessageResponseSchema = z.discriminatedUnion("ok", [ export const PostMessageBodySchema = z.object({ channel: z.string(), text: z.string(), + blocks: z.array(knownBlockSchema).optional(), + username: z.string().optional(), + iconEmoji: z.string().optional(), + iconUrl: z.string().optional(), }); export const ChannelNameOrIdSchema = z.union([ @@ -38,6 +43,10 @@ export const ChannelNameOrIdSchema = z.union([ export const PostMessageOptionsSchema = z .object({ text: z.string(), + blocks: z.array(knownBlockSchema).optional(), + username: z.string().optional(), + iconEmoji: z.string().optional(), + iconUrl: z.string().optional(), }) .and(ChannelNameOrIdSchema); From 1843a2f944ad5f7d91359c88b65ecd0c9847279d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 20:12:43 -0800 Subject: [PATCH 03/39] Created a zod helper that can parse from snake_case to camelCase and vice versa --- packages/trigger-providers/src/schemaUtils.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/trigger-providers/src/schemaUtils.ts diff --git a/packages/trigger-providers/src/schemaUtils.ts b/packages/trigger-providers/src/schemaUtils.ts new file mode 100644 index 00000000000..7d3ca62b851 --- /dev/null +++ b/packages/trigger-providers/src/schemaUtils.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { CamelCasedPropertiesDeep, SnakeCasedPropertiesDeep } from "type-fest"; + +/** Converts a Zod Schema from snake_case to camelCase */ +export function snakeToCamel( + schema: z.ZodType +) { + return schema.transform( + (object) => + deepSnakeToCamel(object) as unknown as CamelCasedPropertiesDeep< + typeof object + > + ); +} + +/** Converts a Zod Schema from camelCase to snake_case */ +export function camelToSnake( + schema: z.ZodType +) { + return schema.transform( + (object) => + deepCamelToSnake(object) as unknown as SnakeCasedPropertiesDeep< + typeof object + > + ); +} + +function deepSnakeToCamel(o: any): T { + if (isObject(o)) { + const n = {}; + + Object.keys(o).forEach((k) => { + // @ts-ignore + n[keySnakeToCamel(k)] = deepSnakeToCamel(o[k]); + }); + + return n as T; + } else if (isArray(o)) { + return o.map((i: any) => { + return deepSnakeToCamel(i); + }); + } + + return o; +} + +function keySnakeToCamel(s: string): string { + return s.replace(/([_][a-z])/gi, ($1) => { + return $1.toUpperCase().replace("-", "").replace("_", ""); + }); +} + +function deepCamelToSnake(o: any): T { + if (isObject(o)) { + const n = {}; + + Object.keys(o).forEach((k) => { + // @ts-ignore + n[keyCamelToSnake(k)] = deepCamelToSnake(o[k]); + }); + + return n as T; + } else if (isArray(o)) { + return o.map((i: any) => { + return deepCamelToSnake(i); + }); + } + + return o; +} + +function keyCamelToSnake(s: string): string { + return s + .replace(/[\w]([A-Z])/g, function (m) { + return m[0] + "_" + m[1]; + }) + .toLowerCase(); +} + +function isArray(a: any): boolean { + return Array.isArray(a); +} + +function isObject(o: any): boolean { + return o === Object(o) && !isArray(o) && typeof o !== "function"; +} From 51c295f10216244f348c698efc04f0dedf92a936 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 20:13:04 -0800 Subject: [PATCH 04/39] Added type-fest to trigger-providers as a dev dependency --- packages/trigger-providers/package.json | 1 + pnpm-lock.yaml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/trigger-providers/package.json b/packages/trigger-providers/package.json index 4d427998fd5..5d9f97711da 100644 --- a/packages/trigger-providers/package.json +++ b/packages/trigger-providers/package.json @@ -22,6 +22,7 @@ "@types/node": "16", "rimraf": "^3.0.2", "tsup": "^6.5.0", + "type-fest": "^3.5.3", "typescript": "^4.9.4" }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba2fc009ae1..bab4da4cf12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -790,6 +790,7 @@ importers: rimraf: ^3.0.2 tiny-invariant: ^1.2.0 tsup: ^6.5.0 + type-fest: ^3.5.3 typescript: ^4.9.4 zod: ^3.20.2 dependencies: @@ -801,6 +802,7 @@ importers: '@types/node': 16.18.11 rimraf: 3.0.2 tsup: 6.5.0_typescript@4.9.4 + type-fest: 3.5.3 typescript: 4.9.4 packages/trigger-sdk: @@ -16018,6 +16020,11 @@ packages: engines: {node: '>=14.16'} dev: true + /type-fest/3.5.3: + resolution: {integrity: sha512-V2+og4j/rWReWvaFrse3s9g2xvUv/K9Azm/xo6CjIuq7oeGqsoimC7+9/A3tfvNcbQf8RPSVj/HV81fB4DJrjA==} + engines: {node: '>=14.16'} + dev: true + /type-is/1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} From 001cd811c4034a410e0e4d9b57a1c5721e0676c8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 20:13:40 -0800 Subject: [PATCH 05/39] Added Slack block schemas --- .../src/providers/slack/blocks.ts | 80 ++++++++++++++----- .../src/providers/slack/schemas.ts | 8 +- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/packages/trigger-providers/src/providers/slack/blocks.ts b/packages/trigger-providers/src/providers/slack/blocks.ts index a2ed9fed64e..ac7ab104b8a 100644 --- a/packages/trigger-providers/src/providers/slack/blocks.ts +++ b/packages/trigger-providers/src/providers/slack/blocks.ts @@ -39,7 +39,10 @@ export const optionSchema = z.union([ export const confirmSchema = z.object({ title: plainTextElementSchema.optional(), - text: z.union([plainTextElementSchema, mrkdwnElementSchema]), + text: z.discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, + ]), confirm: plainTextElementSchema.optional(), deny: plainTextElementSchema.optional(), style: z.union([z.literal("primary"), z.literal("danger")]).optional(), @@ -429,7 +432,11 @@ export const imageBlockSchema = blockSchema.extend({ export const contextBlockSchema = blockSchema.extend({ type: z.literal("context"), elements: z.array( - z.union([imageElementSchema, plainTextElementSchema, mrkdwnElementSchema]) + z.discriminatedUnion("type", [ + imageElementSchema, + plainTextElementSchema, + mrkdwnElementSchema, + ]) ), }); @@ -553,32 +560,41 @@ export const dialogSchema = z.object({ state: z.string().optional(), }); -export const selectSchema = z.union([ +const selectSchemas = [ usersSelectSchema, staticSelectSchema, conversationsSelectSchema, channelsSelectSchema, externalSelectSchema, +]; + +export const selectSchema = z.discriminatedUnion("type", [ + selectSchemas[0], + ...selectSchemas.slice(1), ]); -export const multiSelectSchema = z.union([ +const multiSelectSchemas = [ multiUsersSelectSchema, multiStaticSelectSchema, multiConversationsSelectSchema, multiChannelsSelectSchema, multiExternalSelectSchema, +]; +export const multiSelectSchema = z.discriminatedUnion("type", [ + multiSelectSchemas[0], + ...multiSelectSchemas.slice(1), ]); export const actionsBlockSchema = blockSchema.extend({ type: z.literal("actions"), elements: z.array( - z.union([ + z.discriminatedUnion("type", [ buttonSchema, overflowSchema, datepickerSchema, timepickerSchema, dateTimepickerSchema, - selectSchema, + ...selectSchemas, radioButtonsSchema, checkboxesSchema, actionSchema, @@ -588,18 +604,25 @@ export const actionsBlockSchema = blockSchema.extend({ export const sectionBlockSchema = blockSchema.extend({ type: z.literal("section"), - text: z.union([plainTextElementSchema, mrkdwnElementSchema]).optional(), + text: z + .discriminatedUnion("type", [plainTextElementSchema, mrkdwnElementSchema]) + .optional(), fields: z - .array(z.union([plainTextElementSchema, mrkdwnElementSchema])) + .array( + z.discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, + ]) + ) .optional(), accessory: z - .union([ + .discriminatedUnion("type", [ buttonSchema, overflowSchema, datepickerSchema, timepickerSchema, - selectSchema, - multiSelectSchema, + ...selectSchemas, + ...multiSelectSchemas, actionSchema, imageElementSchema, radioButtonsSchema, @@ -613,10 +636,10 @@ export const inputBlockSchema = blockSchema.extend({ label: plainTextElementSchema, hint: plainTextElementSchema.optional(), optional: z.boolean().optional(), - element: z.union([ - selectSchema, - multiSelectSchema, + element: z.discriminatedUnion("type", [ datepickerSchema, + ...selectSchemas, + ...multiSelectSchemas, timepickerSchema, dateTimepickerSchema, plainTextInputSchema, @@ -680,7 +703,7 @@ export const callUserSchema = z.union([ callUserExternalSchema, ]); -export const knownBlockSchema = z.union([ +const knownBlocks = [ imageBlockSchema, contextBlockSchema, actionsBlockSchema, @@ -690,10 +713,20 @@ export const knownBlockSchema = z.union([ fileBlockSchema, headerBlockSchema, videoBlockSchema, +]; + +export const knownBlockSchema = z.discriminatedUnion("type", [ + knownBlocks[0], + ...knownBlocks.slice(1), +]); + +const anyBlockSchema = z.discriminatedUnion("type", [ + blockSchema, + ...knownBlocks, ]); export const messageAttachmentSchema = z.object({ - blocks: z.array(z.union([knownBlockSchema, blockSchema])).optional(), + blocks: z.array(anyBlockSchema).optional(), fallback: z.string().optional(), color: z .union([ @@ -742,7 +775,7 @@ export const linkUnfurlsSchema = z.record(messageAttachmentSchema); export const homeViewSchema = z.object({ type: z.literal("home"), - blocks: z.array(z.union([knownBlockSchema, blockSchema])), + blocks: z.array(anyBlockSchema), private_metadata: z.string().optional(), callback_id: z.string().optional(), external_id: z.string().optional(), @@ -751,7 +784,7 @@ export const homeViewSchema = z.object({ export const modalViewSchema = z.object({ type: z.literal("modal"), title: plainTextElementSchema, - blocks: z.array(z.union([knownBlockSchema, blockSchema])), + blocks: z.array(anyBlockSchema), close: plainTextElementSchema.optional(), submit: plainTextElementSchema.optional(), private_metadata: z.string().optional(), @@ -763,13 +796,18 @@ export const modalViewSchema = z.object({ export const workflowStepViewSchema = z.object({ type: z.literal("workflow_step"), - blocks: z.array(z.union([knownBlockSchema, blockSchema])), + 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.ZodUnion< +export const viewSchema: z.ZodDiscriminatedUnion< + "type", [typeof homeViewSchema, typeof modalViewSchema, typeof workflowStepViewSchema] -> = z.union([homeViewSchema, modalViewSchema, workflowStepViewSchema]); +> = z.discriminatedUnion("type", [ + homeViewSchema, + modalViewSchema, + workflowStepViewSchema, +]); diff --git a/packages/trigger-providers/src/providers/slack/schemas.ts b/packages/trigger-providers/src/providers/slack/schemas.ts index 064cdb8b10d..dab6b8366fd 100644 --- a/packages/trigger-providers/src/providers/slack/schemas.ts +++ b/packages/trigger-providers/src/providers/slack/schemas.ts @@ -31,8 +31,8 @@ export const PostMessageBodySchema = z.object({ text: z.string(), blocks: z.array(knownBlockSchema).optional(), username: z.string().optional(), - iconEmoji: z.string().optional(), - iconUrl: z.string().optional(), + icon_emoji: z.string().optional(), + icon_url: z.string().optional(), }); export const ChannelNameOrIdSchema = z.union([ @@ -45,8 +45,8 @@ export const PostMessageOptionsSchema = z text: z.string(), blocks: z.array(knownBlockSchema).optional(), username: z.string().optional(), - iconEmoji: z.string().optional(), - iconUrl: z.string().optional(), + icon_emoji: z.string().optional(), + icon_url: z.string().optional(), }) .and(ChannelNameOrIdSchema); From 2ab9b5f97b4b5812aee9394c08a894363a286700 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 20:14:02 -0800 Subject: [PATCH 06/39] Build the service request objects ahead of time so we can easily log them if need to --- .../src/services/index.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/internal-integrations/src/services/index.ts b/packages/internal-integrations/src/services/index.ts index a1b3baddafc..16f4bd72517 100644 --- a/packages/internal-integrations/src/services/index.ts +++ b/packages/internal-integrations/src/services/index.ts @@ -32,17 +32,19 @@ export class HttpService { endpoint: HttpEndpoint, body?: z.infer ): Promise> { - const response = await fetch( - `${this.options.baseUrl}${endpoint.options.path}`, - { - method: endpoint.options.method, - headers: { - Authorization: `Bearer ${this.options.accessToken}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: body ? JSON.stringify(body) : undefined, - } - ); + const url = `${this.options.baseUrl}${endpoint.options.path}`; + const method = endpoint.options.method; + const headers = { + Authorization: `Bearer ${this.options.accessToken}`, + "Content-Type": "application/json; charset=utf-8", + }; + const actualBody = body ? JSON.stringify(body) : undefined; + + const response = await fetch(url, { + method, + headers, + body: actualBody, + }); log( "%s%s response %d", From 972444da16e045f32734ca9b75c1fd69da96490c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 20:14:13 -0800 Subject: [PATCH 07/39] Slack example with lots of blocks --- examples/send-to-slack/src/index.ts | 89 ++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/examples/send-to-slack/src/index.ts b/examples/send-to-slack/src/index.ts index 62ab9472a59..f5186adf0b2 100644 --- a/examples/send-to-slack/src/index.ts +++ b/examples/send-to-slack/src/index.ts @@ -24,6 +24,94 @@ new Trigger({ const response = await slack.postMessage("send-to-slack", { channelName: "test-integrations", text: `New domain created: ${event.domain} by customer ${event.customerId} cc @Eric #general`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "Hello, Assistant to the Regional Manager Dwight! *Michael Scott* wants to know where you'd like to take the Paper Company investors to dinner tonight.\n\n *Please select a restaurant:*", + }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*Farmhouse Thai Cuisine*\n:star::star::star::star: 1528 reviews\n They do have some vegan options, like the roti and curry, plus they have a ton of salad stuff and noodles can be ordered without meat!! They have something for everyone here", + }, + accessory: { + type: "image", + image_url: + "https://s3-media3.fl.yelpcdn.com/bphoto/c7ed05m9lC2EmA3Aruue7A/o.jpg", + alt_text: "alt text for image", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*Kin Khao*\n:star::star::star::star: 1638 reviews\n The sticky rice also goes wonderfully with the caramelized pork belly, which is absolutely melt-in-your-mouth and so soft.", + }, + accessory: { + type: "image", + image_url: + "https://s3-media2.fl.yelpcdn.com/bphoto/korel-1YjNtFtJlMTaC26A/o.jpg", + alt_text: "alt text for image", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*Ler Ros*\n:star::star::star::star: 2082 reviews\n I would really recommend the Yum Koh Moo Yang - Spicy lime dressing and roasted quick marinated pork shoulder, basil leaves, chili & rice powder.", + }, + accessory: { + type: "image", + image_url: + "https://s3-media2.fl.yelpcdn.com/bphoto/DawwNigKJ2ckPeDeDM7jAg/o.jpg", + alt_text: "alt text for image", + }, + }, + { + type: "divider", + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Farmhouse", + emoji: true, + }, + value: "click_me_123", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Kin Khao", + emoji: true, + }, + value: "click_me_123", + url: "https://google.com", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Ler Ros", + emoji: true, + }, + value: "click_me_123", + url: "https://google.com", + }, + ], + }, + ], }); await ctx.waitFor("initial-wait", { seconds: 5 }); @@ -36,4 +124,3 @@ new Trigger({ return {}; }, }).listen(); - From 109d81d9fa6ae6b6d55d4aff14b3279a59057573 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 21:53:02 -0800 Subject: [PATCH 08/39] =?UTF-8?q?Slack=20blocks=20schemas=20are=20finally?= =?UTF-8?q?=20parsing=20properly=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/providers/slack/blocks.ts | 342 ++++++++++-------- 1 file changed, 195 insertions(+), 147 deletions(-) diff --git a/packages/trigger-providers/src/providers/slack/blocks.ts b/packages/trigger-providers/src/providers/slack/blocks.ts index ac7ab104b8a..514b9725df9 100644 --- a/packages/trigger-providers/src/providers/slack/blocks.ts +++ b/packages/trigger-providers/src/providers/slack/blocks.ts @@ -75,7 +75,16 @@ 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/handling#payloads identify the source of the action}. Should be unique + * {@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(), @@ -114,30 +123,29 @@ export const dispatchableSchema = z.object({ dispatch_action_config: dispatchActionConfigSchema.optional(), }); -export const usersSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +export const usersSelectSchema = z + .object({ type: z.literal("users_select"), initial_user: z.string().optional(), - }); - -export const multiUsersSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), - }); - -export const staticSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), @@ -149,13 +157,14 @@ export const staticSelectSchema = actionSchema }) ) .optional(), - }); - -export const multiStaticSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), @@ -168,13 +177,14 @@ export const multiStaticSelectSchema = actionSchema ) .optional(), max_selected_items: z.number().optional(), - }); - -export const conversationsSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), @@ -195,13 +205,14 @@ export const conversationsSelectSchema = actionSchema exclude_bot_users: z.boolean().optional(), }) .optional(), - }); - -export const multiConversationsSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), @@ -222,128 +233,140 @@ export const multiConversationsSelectSchema = actionSchema exclude_bot_users: z.boolean().optional(), }) .optional(), - }); - -export const channelsSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .merge(actionIdSchema) + .merge(confirmableSchema) + .merge(focusableSchema) + .merge(placeholdableSchema); + +export const channelsSelectSchema = z + .object({ type: z.literal("channels_select"), initial_channel: z.string().optional(), - }); - -export const multiChannelsSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), - }); - -export const externalSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), - }); - -export const multiExternalSelectSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), - }); - -export const buttonSchema = actionSchema - .extend(confirmableSchema.shape) - .extend({ + }) + .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 = actionSchema - .extend(confirmableSchema.shape) - .extend({ +export const overflowSchema = z + .object({ type: z.literal("overflow"), options: z.array(plainTextOptionSchema), - }); + }) + .merge(actionIdSchema) + .merge(confirmableSchema); -export const datepickerSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +export const datepickerSchema = z + .object({ type: z.literal("datepicker"), initial_date: z.string().optional(), - }); - -export const timepickerSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ + }) + .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(), - }); - -export const radioButtonsSchema = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend({ + }) + .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 and 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 and date + * 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 = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend({ +export const dateTimepickerSchema = z + .object({ type: z.literal("datetimepicker"), /** - * @description The initial date and 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 and + * @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 = actionSchema - .extend(confirmableSchema.shape) - .extend(focusableSchema.shape) - .extend({ +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 = actionSchema - .extend(dispatchableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +export const plainTextInputSchema = z + .object({ type: z.literal("plain_text_input"), initial_value: z.string().optional(), multiline: z.boolean().optional(), @@ -351,53 +374,56 @@ export const plainTextInputSchema = actionSchema 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 = actionSchema - .extend(dispatchableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +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 = actionSchema - .extend(dispatchableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +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, and -10 are all + * 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 = actionSchema - .extend(dispatchableSchema.shape) - .extend(focusableSchema.shape) - .extend(placeholdableSchema.shape) - .extend({ +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. @@ -415,7 +441,11 @@ export const numberInputSchema = actionSchema * @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(), @@ -569,8 +599,11 @@ const selectSchemas = [ ]; export const selectSchema = z.discriminatedUnion("type", [ - selectSchemas[0], - ...selectSchemas.slice(1), + usersSelectSchema, + staticSelectSchema, + conversationsSelectSchema, + channelsSelectSchema, + externalSelectSchema, ]); const multiSelectSchemas = [ @@ -581,8 +614,11 @@ const multiSelectSchemas = [ multiExternalSelectSchema, ]; export const multiSelectSchema = z.discriminatedUnion("type", [ - multiSelectSchemas[0], - ...multiSelectSchemas.slice(1), + multiUsersSelectSchema, + multiStaticSelectSchema, + multiConversationsSelectSchema, + multiChannelsSelectSchema, + multiExternalSelectSchema, ]); export const actionsBlockSchema = blockSchema.extend({ @@ -597,7 +633,6 @@ export const actionsBlockSchema = blockSchema.extend({ ...selectSchemas, radioButtonsSchema, checkboxesSchema, - actionSchema, ]) ), }); @@ -623,7 +658,6 @@ export const sectionBlockSchema = blockSchema.extend({ timepickerSchema, ...selectSchemas, ...multiSelectSchemas, - actionSchema, imageElementSchema, radioButtonsSchema, checkboxesSchema, @@ -716,13 +750,27 @@ const knownBlocks = [ ]; export const knownBlockSchema = z.discriminatedUnion("type", [ - knownBlocks[0], - ...knownBlocks.slice(1), + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, ]); const anyBlockSchema = z.discriminatedUnion("type", [ - blockSchema, - ...knownBlocks, + imageBlockSchema, + contextBlockSchema, + actionsBlockSchema, + dividerBlockSchema, + sectionBlockSchema, + inputBlockSchema, + fileBlockSchema, + headerBlockSchema, + videoBlockSchema, ]); export const messageAttachmentSchema = z.object({ From e607281b1bf6b51cf683f72860aeab0f15c12e70 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 23:27:56 -0800 Subject: [PATCH 09/39] Pass Zod issues back from HTTPService, so they can be displayed --- .../internal-integrations/src/services/index.ts | 13 ++++++++++++- packages/internal-integrations/src/slack/index.ts | 2 +- .../src/providers/slack/schemas.ts | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/internal-integrations/src/services/index.ts b/packages/internal-integrations/src/services/index.ts index 16f4bd72517..512829b9465 100644 --- a/packages/internal-integrations/src/services/index.ts +++ b/packages/internal-integrations/src/services/index.ts @@ -9,7 +9,10 @@ export type HttpServiceOptions = { baseUrl: string; }; -export type HttpResponse = +export type HttpResponse< + TResponseSchema extends z.ZodTypeAny, + TErrorResponseSchema extends z.ZodTypeAny = z.AnyZodObject +> = | { success: true; statusCode: number; @@ -20,6 +23,7 @@ export type HttpResponse = success: false; statusCode: number; headers: Record; + error?: z.infer; }; export class HttpService { @@ -79,6 +83,13 @@ export class HttpService { parsedJson, parsedJson.error ); + + return { + success: false, + statusCode: response.status, + headers: normalizeHeaders(response.headers), + error: { type: "zod-parse", issues: parsedJson.error.issues }, + }; } return { diff --git a/packages/internal-integrations/src/slack/index.ts b/packages/internal-integrations/src/slack/index.ts index 94cd52d3374..e4c9a4b7792 100644 --- a/packages/internal-integrations/src/slack/index.ts +++ b/packages/internal-integrations/src/slack/index.ts @@ -136,7 +136,7 @@ class SlackRequestIntegration implements RequestIntegration { ok: false, isRetryable: this.#isRetryable(response.statusCode), response: { - output: {}, + output: response.error, context: { statusCode: response.statusCode, headers: response.headers, diff --git a/packages/trigger-providers/src/providers/slack/schemas.ts b/packages/trigger-providers/src/providers/slack/schemas.ts index dab6b8366fd..7e075949f9f 100644 --- a/packages/trigger-providers/src/providers/slack/schemas.ts +++ b/packages/trigger-providers/src/providers/slack/schemas.ts @@ -7,7 +7,7 @@ export const PostMessageSuccessResponseSchema = z.object({ ts: z.string(), message: z.object({ text: z.string(), - user: z.string(), + user: z.string().optional(), bot_id: z.string(), attachments: z.array(z.unknown()).optional(), type: z.string(), From bce1bd13146333b2779c8f4ec78234d186a789d1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 23:33:27 -0800 Subject: [PATCH 10/39] =?UTF-8?q?Example=20with=20a=20Slack=20post=20that?= =?UTF-8?q?=20looks=20like=20it=E2=80=99s=20from=20someone=20else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/send-to-slack/src/index.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/examples/send-to-slack/src/index.ts b/examples/send-to-slack/src/index.ts index f5186adf0b2..2efec4c8eba 100644 --- a/examples/send-to-slack/src/index.ts +++ b/examples/send-to-slack/src/index.ts @@ -116,11 +116,29 @@ new Trigger({ await ctx.waitFor("initial-wait", { seconds: 5 }); - const secondResponse = await slack.postMessage("send-to-slack-channel-id", { - channelId: response.channel, - text: `Sent using the channelId: ${response.channel}`, + await slack.postMessage("arnie", { + username: "Arnie", + icon_url: + "https://www.themoviedb.org/t/p/w500/zEMhugsgXIpnQqO31GpAJYMUZZ1.jpg", + channelName: "test-integrations", + text: getRandomQuote(), }); return {}; }, }).listen(); + +function getRandomQuote() { + const arnoldQuotes = [ + "I'll be back.", + "Strength does not come from winning. Your struggles develop your strengths. When you go through hardships and decide not to surrender, that is strength.", + "The mind is the limit. As long as the mind can envision the fact that you can do something, you can do it, as long as you really believe 100 percent.", + "Success is not the key to happiness. Happiness is the key to success. If you love what you are doing, you will be successful.", + "For me life is continuously being hungry. The meaning of life is not simply to exist, to survive, but to move ahead, to go up, to achieve, to conquer.", + "The best activities for your health are pumping and humping.", + "I have a love interest in every one of my films: a gun.", + "You can have results or excuses, but not both.", + ]; + + return arnoldQuotes[Math.floor(Math.random() * arnoldQuotes.length)]; +} From 764f5c1fbba35da0e13aa883e97168567ec2c115 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 24 Jan 2023 23:33:49 -0800 Subject: [PATCH 11/39] IntegrationRequest input code block has a max height on it --- .../workflows/$workflowSlug/runs/$runId.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx index 0b143e9165c..6e144aff881 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx @@ -678,7 +678,11 @@ function IntegrationRequestStep({ {request.input && ( <> - + )} From 33128bc124d7182a834eea462d77ef188591e656 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jan 2023 12:23:20 -0800 Subject: [PATCH 12/39] Move Slack requests to a separate file --- .../internal-integrations/src/slack/index.ts | 247 +----------------- .../src/slack/requests.ts | 246 +++++++++++++++++ 2 files changed, 248 insertions(+), 245 deletions(-) create mode 100644 packages/internal-integrations/src/slack/requests.ts diff --git a/packages/internal-integrations/src/slack/index.ts b/packages/internal-integrations/src/slack/index.ts index e4c9a4b7792..0f933af238c 100644 --- a/packages/internal-integrations/src/slack/index.ts +++ b/packages/internal-integrations/src/slack/index.ts @@ -1,246 +1,3 @@ -import { HttpEndpoint, HttpService } from "../services"; -import { - DisplayProperties, - CacheService, - PerformedRequestResponse, - PerformRequestOptions, - RequestIntegration, - AccessInfo, -} from "../types"; -import { slack } from "@trigger.dev/providers"; -import debug from "debug"; -import { getAccessToken } from "../accessInfo"; -import { z } from "zod"; -import type { ReactNode } from "react"; +import { requests } from "./requests"; -const log = debug("trigger:integrations:slack"); - -const SendSlackMessageRequestBodySchema = - slack.schemas.PostMessageBodySchema.extend({ - link_names: z.literal(1), - }); - -class SlackRequestIntegration implements RequestIntegration { - #joinChannelEndpoint = new HttpEndpoint< - typeof slack.schemas.JoinConversationResponseSchema, - typeof slack.schemas.JoinConversationBodySchema - >({ - response: slack.schemas.JoinConversationResponseSchema, - method: "POST", - path: "/conversations.join", - }); - - #listConversationsEndpoint = new HttpEndpoint({ - response: slack.schemas.ListConversationsResponseSchema, - method: "GET", - path: "/conversations.list", - }); - - #postMessageEndpoint = new HttpEndpoint< - typeof slack.schemas.PostMessageResponseSchema, - typeof SendSlackMessageRequestBodySchema - >({ - response: slack.schemas.PostMessageResponseSchema, - method: "POST", - path: "/chat.postMessage", - }); - - 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 - ); - } - 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, - }, - ], - }; - } - default: { - throw new Error(`Unknown endpoint: ${endpoint}`); - } - } - } - - renderComponent(input: any, output: any): ReactNode { - return null; - } - - async #postMessage( - accessInfo: AccessInfo, - params: any, - cache?: CacheService - ): Promise { - const parsedParams = slack.schemas.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, - }); - - 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; - } - - #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; - } -} - -export const requests = new SlackRequestIntegration(); +export { requests }; diff --git a/packages/internal-integrations/src/slack/requests.ts b/packages/internal-integrations/src/slack/requests.ts new file mode 100644 index 00000000000..e4c9a4b7792 --- /dev/null +++ b/packages/internal-integrations/src/slack/requests.ts @@ -0,0 +1,246 @@ +import { HttpEndpoint, HttpService } from "../services"; +import { + DisplayProperties, + CacheService, + PerformedRequestResponse, + PerformRequestOptions, + RequestIntegration, + AccessInfo, +} from "../types"; +import { slack } from "@trigger.dev/providers"; +import debug from "debug"; +import { getAccessToken } from "../accessInfo"; +import { z } from "zod"; +import type { ReactNode } from "react"; + +const log = debug("trigger:integrations:slack"); + +const SendSlackMessageRequestBodySchema = + slack.schemas.PostMessageBodySchema.extend({ + link_names: z.literal(1), + }); + +class SlackRequestIntegration implements RequestIntegration { + #joinChannelEndpoint = new HttpEndpoint< + typeof slack.schemas.JoinConversationResponseSchema, + typeof slack.schemas.JoinConversationBodySchema + >({ + response: slack.schemas.JoinConversationResponseSchema, + method: "POST", + path: "/conversations.join", + }); + + #listConversationsEndpoint = new HttpEndpoint({ + response: slack.schemas.ListConversationsResponseSchema, + method: "GET", + path: "/conversations.list", + }); + + #postMessageEndpoint = new HttpEndpoint< + typeof slack.schemas.PostMessageResponseSchema, + typeof SendSlackMessageRequestBodySchema + >({ + response: slack.schemas.PostMessageResponseSchema, + method: "POST", + path: "/chat.postMessage", + }); + + 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 + ); + } + 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, + }, + ], + }; + } + default: { + throw new Error(`Unknown endpoint: ${endpoint}`); + } + } + } + + renderComponent(input: any, output: any): ReactNode { + return null; + } + + async #postMessage( + accessInfo: AccessInfo, + params: any, + cache?: CacheService + ): Promise { + const parsedParams = slack.schemas.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, + }); + + 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; + } + + #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; + } +} + +export const requests = new SlackRequestIntegration(); From 31a8e364de4635bceb5789cbe20d37bf9a493c16 Mon Sep 17 00:00:00 2001 From: D-K-P Date: Thu, 26 Jan 2023 11:44:59 -0800 Subject: [PATCH 13/39] Edited YT iframe and added blurb to the resend examples box --- apps/docs/examples/examples.mdx | 4 +++- apps/docs/welcome.mdx | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/docs/examples/examples.mdx b/apps/docs/examples/examples.mdx index c8196f5e109..e1addc36d8c 100644 --- a/apps/docs/examples/examples.mdx +++ b/apps/docs/examples/examples.mdx @@ -17,5 +17,7 @@ You can use these workflows in your product by following the instructions on eac Post to Slack when a GitHub issue is created or modified. - + + Create a welcome email drip campaign. + diff --git a/apps/docs/welcome.mdx b/apps/docs/welcome.mdx index 98c6b3043e9..816a75828ea 100644 --- a/apps/docs/welcome.mdx +++ b/apps/docs/welcome.mdx @@ -12,15 +12,15 @@ _A workflow in action:_ ![Test a run](/images/workflow-demo.gif) - +  Quickly get up and running with Trigger.dev by following our quick start From 1a20d1b2a3f3eb651ceb790f82adea734f59ef2d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jan 2023 12:53:49 -0800 Subject: [PATCH 14/39] Added explaination on how to send an event using cURL to the docs --- apps/docs/functions/send-event.mdx | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/docs/functions/send-event.mdx b/apps/docs/functions/send-event.mdx index b709fcaaae7..bb299ac2151 100644 --- a/apps/docs/functions/send-event.mdx +++ b/apps/docs/functions/send-event.mdx @@ -4,9 +4,12 @@ sidebarTitle: "Send event" description: "Send events to trigger your custom events workflows." --- -## How to send an event +## Send an event -You can send an event from anywhere in your code, including from inside another workflow. +You can easily send an event from anywhere, including from inside another workflow. Events don't have to come from the same server as your workflow and can be sent as HTTP requests from any language (see our cURL example below for the format). + + + ```ts import { sendEvent } from "@trigger.dev/sdk"; @@ -19,6 +22,28 @@ await sendEvent("start-fire-2", { }); ``` + + + +```bash +curl --request POST \ + --url https://app.trigger.dev/api/v1/events \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "id": "", + "event": { + "name": "user.created", + "payload": { + "userId": "123456" + } + } + }' +``` + + + + If you are calling this from inside a workflow, ensure that the first parameter is unique inside your workflow. ## Sending events from other workflows @@ -28,3 +53,7 @@ It is useful to send events from workflows because you can split your logic into ## Writing a workflow that is triggered by an event You should read [the documentation on custom events](/triggers/custom-events) to learn how to write a workflow that is triggered by an event. + +``` + +``` From 518472fcb2fc930a9510c361dc52a4398f26a2ba Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jan 2023 13:02:08 -0800 Subject: [PATCH 15/39] Fix for page count being wrong on runs table --- apps/webapp/app/models/workflowRunListPresenter.server.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/app/models/workflowRunListPresenter.server.ts b/apps/webapp/app/models/workflowRunListPresenter.server.ts index 470c5b8aad7..e9fa5e4e0d3 100644 --- a/apps/webapp/app/models/workflowRunListPresenter.server.ts +++ b/apps/webapp/app/models/workflowRunListPresenter.server.ts @@ -54,6 +54,14 @@ export class WorkflowRunListPresenter { where: { workflow: { slug: workflowSlug, + organization: { + slug: organizationSlug, + users: { + some: { + id: userId, + }, + }, + }, }, environment: { slug: environmentSlug, From a4bd86fefe40e63c4ca35d5e6655d52764d110c2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 26 Jan 2023 13:04:36 -0800 Subject: [PATCH 16/39] =?UTF-8?q?Added=20a=20typeform=20to=20vote=20for=20?= =?UTF-8?q?integrations=20we=20don=E2=80=99t=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/CreateNewWorkflow.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/CreateNewWorkflow.tsx b/apps/webapp/app/components/CreateNewWorkflow.tsx index 92961e3930b..f93612ba9a8 100644 --- a/apps/webapp/app/components/CreateNewWorkflow.tsx +++ b/apps/webapp/app/components/CreateNewWorkflow.tsx @@ -84,14 +84,22 @@ export function CreateNewWorkflowNoWorkflows() { Easily authenticate with APIs using the supported integrations below. If there's an integration we don't yet support,{" "} - + {" "} and we'll add it. +
{getProviders(false).map((provider) => ( Fetch + & + + Webhooks +
Join the community From e5530299dac364e0ee5804d277374d7342537092 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jan 2023 13:07:14 -0800 Subject: [PATCH 17/39] Scope the test data to the correct organization --- apps/webapp/app/models/workflowRun.server.ts | 5 +++++ .../$organizationSlug/workflows/$workflowSlug/test.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/models/workflowRun.server.ts b/apps/webapp/app/models/workflowRun.server.ts index aaa6194939c..77c5d976b68 100644 --- a/apps/webapp/app/models/workflowRun.server.ts +++ b/apps/webapp/app/models/workflowRun.server.ts @@ -228,13 +228,18 @@ async function findWorkflowRunScopedToApiKey(id: string, apiKey: string) { export async function getMostRecentWorkflowRun({ workflowSlug, + organizationSlug, }: { workflowSlug: string; + organizationSlug: string; }) { return prisma.workflowRun.findFirst({ where: { workflow: { slug: workflowSlug, + organization: { + slug: organizationSlug, + }, }, }, include: { diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx index f17914345af..f2ba3ac848d 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx @@ -18,11 +18,15 @@ import { requireUserId } from "~/services/session.server"; export const loader = async ({ request, params }: LoaderArgs) => { await requireUserId(request); - const { workflowSlug } = params; + const { workflowSlug, organizationSlug } = params; invariant(workflowSlug, "workflowSlug is required"); + invariant(organizationSlug, "organizationSlug is required"); try { - const latestRun = await getMostRecentWorkflowRun({ workflowSlug }); + const latestRun = await getMostRecentWorkflowRun({ + workflowSlug, + organizationSlug, + }); return typedjson({ latestRun }); } catch (error: any) { console.error(error); From 86c543c45f726d146b1885a9c733480228f4e23f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 26 Jan 2023 23:29:05 +0000 Subject: [PATCH 18/39] Fire off internal custom events for user.created and workflow.created --- apps/webapp/app/env.server.ts | 1 + apps/webapp/app/services/emailAuth.server.tsx | 19 ++++++++------ .../app/services/events/ingest.server.ts | 13 ++++++++-- apps/webapp/app/services/gitHubAuth.server.ts | 11 ++++++++ .../app/services/messageBroker.server.ts | 25 +++++++++++++++++++ .../workflows/registerWorkflow.server.ts | 22 ++++++++++++++++ flightcontrol.json | 8 +++--- 7 files changed, 86 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 43d8bde6a3c..dda42842f76 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -40,6 +40,7 @@ const EnvironmentSchema = z.object({ PULSAR_ISSUER_URL: z.string().optional(), PULSAR_AUDIENCE: z.string().optional(), PULSAR_DEBUG: z.string().optional(), + INTERNAL_TRIGGER_API_KEY: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/services/emailAuth.server.tsx b/apps/webapp/app/services/emailAuth.server.tsx index 83785dff738..23af9558570 100644 --- a/apps/webapp/app/services/emailAuth.server.tsx +++ b/apps/webapp/app/services/emailAuth.server.tsx @@ -6,6 +6,7 @@ import { findOrCreateUser } from "~/models/user.server"; import { env } from "~/env.server"; import { createFirstOrganization } from "~/models/organization.server"; import { sendMagicLinkEmail } from "~/services/email.server"; +import { taskQueue } from "./messageBroker.server"; let secret = env.MAGIC_LINK_SECRET; if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable."); @@ -32,15 +33,19 @@ const emailStrategy = new EmailLinkStrategy( authenticationMethod: "MAGIC_LINK", }); - console.log( - `User ${user.id} logged in with magic link. ${ - isNewUser ? "New user." : "" - }` - ); - if (isNewUser) { await createFirstOrganization(user); - emailProvider.scheduleWelcomeEmail(user); + await emailProvider.scheduleWelcomeEmail(user); + + await taskQueue.publish("SEND_INTERNAL_EVENT", { + id: user.id, + name: "user.created", + payload: { + id: user.id, + source: "MAGIC_LINK", + admin: user.admin, + }, + }); } return { userId: user.id }; diff --git a/apps/webapp/app/services/events/ingest.server.ts b/apps/webapp/app/services/events/ingest.server.ts index b3ba11f1ccf..6ed3c602020 100644 --- a/apps/webapp/app/services/events/ingest.server.ts +++ b/apps/webapp/app/services/events/ingest.server.ts @@ -25,20 +25,29 @@ export class IngestEvent { this.#prismaClient = prismaClient; } - public async call(options: IngestEventOptions, organization: Organization) { + public async call(options: IngestEventOptions, organization?: Organization) { const environment = options.apiKey ? await findEnvironmentByApiKey(options.apiKey) : undefined; + if (!environment && !organization) { + return { + status: "error" as const, + error: "No organization or environment found", + }; + } + const id = options.id ?? ulid(); + const organizationId = organization?.id ?? environment?.organizationId; + // Create a new event in the database const event = await this.#prismaClient.triggerEvent.create({ data: { id, organization: { connect: { - id: organization.id, + id: organizationId, }, }, environment: environment diff --git a/apps/webapp/app/services/gitHubAuth.server.ts b/apps/webapp/app/services/gitHubAuth.server.ts index a32f3af627e..7b5b1a5cc57 100644 --- a/apps/webapp/app/services/gitHubAuth.server.ts +++ b/apps/webapp/app/services/gitHubAuth.server.ts @@ -5,6 +5,7 @@ import { createFirstOrganization } from "~/models/organization.server"; import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { scheduleWelcomeEmail } from "./email.server"; +import { taskQueue } from "./messageBroker.server"; const gitHubStrategy = new GitHubStrategy( { @@ -31,6 +32,16 @@ const gitHubStrategy = new GitHubStrategy( if (isNewUser) { await createFirstOrganization(user); await scheduleWelcomeEmail(user); + + await taskQueue.publish("SEND_INTERNAL_EVENT", { + id: user.id, + name: "user.created", + payload: { + id: user.id, + source: "GITHUB", + admin: user.admin, + }, + }); } return { diff --git a/apps/webapp/app/services/messageBroker.server.ts b/apps/webapp/app/services/messageBroker.server.ts index 2e504053017..ddff46a03e6 100644 --- a/apps/webapp/app/services/messageBroker.server.ts +++ b/apps/webapp/app/services/messageBroker.server.ts @@ -1,5 +1,6 @@ import type { FetchOutputSchema } from "@trigger.dev/common-schemas"; import { + CustomEventSchema, JsonSchema, ScheduledEventPayloadSchema, } from "@trigger.dev/common-schemas"; @@ -34,6 +35,7 @@ import { InitiateDelay } from "./delays/initiateDelay.server"; import { ResolveDelay } from "./delays/resolveDelay.server"; import { sendEmail } from "./email.server"; import { DispatchEvent } from "./events/dispatch.server"; +import { IngestEvent } from "./events/ingest.server"; import { HandleNewServiceConnection } from "./externalServices/handleNewConnection.server"; import { RegisterExternalSource } from "./externalSources/registerExternalSource.server"; import { CreateFetchRequest } from "./fetches/createFetchRequest.server"; @@ -419,6 +421,10 @@ const taskQueueCatalog = { }), properties: z.object({}), }, + SEND_INTERNAL_EVENT: { + data: CustomEventSchema.extend({ id: z.string() }), + properties: z.object({}), + }, }; function createTaskQueue() { @@ -679,6 +685,25 @@ function createTaskQueue() { return service.call(data.externalSourceId, data.payload); }, + SEND_INTERNAL_EVENT: async (id, data, properties, attributes) => { + if (attributes.redeliveryCount >= 4) { + return true; + } + + const service = new IngestEvent(); + + const result = await service.call({ + id: data.id, + name: data.name, + type: "CUSTOM_EVENT", + service: "trigger", + payload: data.payload, + context: data.context, + apiKey: env.INTERNAL_TRIGGER_API_KEY, + }); + + return result.status === "success"; + }, }, }); diff --git a/apps/webapp/app/services/workflows/registerWorkflow.server.ts b/apps/webapp/app/services/workflows/registerWorkflow.server.ts index d4b027d629c..750819cec73 100644 --- a/apps/webapp/app/services/workflows/registerWorkflow.server.ts +++ b/apps/webapp/app/services/workflows/registerWorkflow.server.ts @@ -99,6 +99,18 @@ export class RegisterWorkflow { payload: WorkflowMetadata, organization: Organization ) { + const existingWorkflow = await this.#prismaClient.workflow.findUnique({ + where: { + organizationId_slug: { + organizationId: organization.id, + slug, + }, + }, + select: { + id: true, + }, + }); + const workflow = await this.#prismaClient.workflow.upsert({ where: { organizationId_slug: { @@ -130,6 +142,16 @@ export class RegisterWorkflow { }, }); + if (!existingWorkflow) { + await taskQueue.publish("SEND_INTERNAL_EVENT", { + id: workflow.id, + name: "workflow.created", + payload: { + id: workflow.id, + }, + }); + } + return workflow; } diff --git a/flightcontrol.json b/flightcontrol.json index c9d599df4ce..b2f3d87451a 100644 --- a/flightcontrol.json +++ b/flightcontrol.json @@ -107,6 +107,9 @@ "fromParameterStore": "/Prod/webapp/POSTHOG_PROJECT_KEY" }, "TRIGGER_LOG_LEVEL": "debug", + "INTERNAL_TRIGGER_API_KEY": { + "fromParameterStore": "/Prod/webapp/INTERNAL_TRIGGER_API_KEY" + }, "PULSAR_ENABLED": "1", "PULSAR_DEBUG": true } @@ -147,10 +150,7 @@ "fromParameterStore": "/Prod/Pulsar/wss/Role" }, "PLATFORM_API_URL": "https://app.trigger.dev", - "TRIGGER_LOG_LEVEL": "debug", - "TRIGGER_API_KEY": { - "fromParameterStore": "/Prod/webapp/TRIGGER_API_KEY" - } + "TRIGGER_LOG_LEVEL": "debug" } }, { From 7e7c8dafd7de3ef3f3211a403e39ce3d432684c2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 00:24:19 -0800 Subject: [PATCH 19/39] Slack interactivity hook responds with 200 --- .../internal/webhooks/slack/interactivity.ts | 5 + apps/webapp/app/utils/formData.ts | 10 ++ .../src/providers/slack/interactivity.ts | 111 ++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts create mode 100644 apps/webapp/app/utils/formData.ts create mode 100644 packages/trigger-providers/src/providers/slack/interactivity.ts diff --git a/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts b/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts new file mode 100644 index 00000000000..c9798e7de10 --- /dev/null +++ b/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts @@ -0,0 +1,5 @@ +import type { ActionArgs } from "@remix-run/server-runtime"; + +export async function action({ request }: ActionArgs) { + return { status: 200 }; +} diff --git a/apps/webapp/app/utils/formData.ts b/apps/webapp/app/utils/formData.ts new file mode 100644 index 00000000000..8290365263a --- /dev/null +++ b/apps/webapp/app/utils/formData.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; + +export function formDataAsObject( + formData: FormData, + schema: z.ZodSchema +): TValues { + const object = Object.fromEntries(formData.entries()); + const parsed = schema.parse(object); + return parsed; +} diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts new file mode 100644 index 00000000000..1bb3f018262 --- /dev/null +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { plainTextElementSchema } from "./blocks"; + +const blockActionType = z.union([ + z.literal("block_actions"), + z.literal("interactive_message"), +]); + +const sourceType = z.literal("message"); + +export const blockAction = z.object({ + type: blockActionType, + user: z.object({ + id: z.string(), + username: z.string(), + name: z.string(), + team_id: z.string(), + }), + api_app_id: z.string(), + container: z.object({ + type: sourceType, + message_ts: z.string(), + channel_id: z.string(), + is_ephemeral: z.boolean(), + }), + trigger_id: z.string(), + team: z.object({ id: z.string(), domain: z.string() }), + enterprise: z.null(), + is_enterprise_install: z.boolean(), + channel: z.object({ id: z.string(), name: z.string() }), + message: z + .object({ + bot_id: z.string(), + type: sourceType, + text: z.string(), + user: z.string(), + ts: z.string(), + app_id: z.string(), + blocks: z.array( + z.union([ + z.object({ + type: z.string(), + block_id: z.string(), + text: z.object({ + type: z.string(), + text: z.string(), + verbatim: z.boolean(), + }), + }), + z.object({ type: z.string(), block_id: z.string() }), + z.object({ + type: z.string(), + block_id: z.string(), + text: z.object({ + type: z.string(), + text: z.string(), + verbatim: z.boolean(), + }), + accessory: z.object({ + type: z.string(), + image_url: z.string(), + alt_text: z.string(), + }), + }), + z.object({ + type: z.string(), + block_id: z.string(), + elements: z.array( + z.union([ + z.object({ + type: z.string(), + action_id: z.string(), + text: z.object({ + type: z.string(), + text: z.string(), + emoji: z.boolean(), + }), + value: z.string(), + }), + z.object({ + type: z.string(), + action_id: z.string(), + text: z.object({ + type: z.string(), + text: z.string(), + emoji: z.boolean(), + }), + value: z.string(), + url: z.string(), + }), + ]) + ), + }), + ]) + ), + team: z.string(), + }) + .optional(), + state: z.object({ values: z.object({}) }), + response_url: z.string(), + actions: z.array( + z.object({ + action_id: z.string(), + block_id: z.string(), + text: plainTextElementSchema, + value: z.string(), + type: z.string(), + action_ts: z.string(), + }) + ), +}); From 52d21ac84df6d001b904864d9a677b36b662ccdc Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 27 Jan 2023 10:18:13 +0000 Subject: [PATCH 20/39] Added support for delaying delivery when sending custom events --- .changeset/cuddly-ligers-reflect.md | 5 + apps/docs/functions/send-event.mdx | 150 +++++++++++++++- apps/docs/mint.json | 3 +- apps/docs/reference/send-event.mdx | 97 +++++++++++ apps/webapp/app/models/workflowRun.server.ts | 30 ++-- .../workflows/$workflowSlug/runs/$runId.tsx | 36 +++- apps/webapp/app/routes/api/v1/events.ts | 27 ++- .../$organizationSlug/test/$workflowSlug.ts | 24 ++- .../events/ingestCustomEvent.server.ts | 60 +++++++ .../app/services/messageBroker.server.ts | 38 +++- apps/webapp/app/utils/json.ts | 7 + apps/webapp/app/utils/objects.ts | 14 ++ examples/smoke-test/src/index.ts | 20 +++ packages/common-schemas/src/events.ts | 18 ++ pnpm-lock.yaml | 162 ------------------ 15 files changed, 478 insertions(+), 213 deletions(-) create mode 100644 .changeset/cuddly-ligers-reflect.md create mode 100644 apps/docs/reference/send-event.mdx create mode 100644 apps/webapp/app/services/events/ingestCustomEvent.server.ts create mode 100644 apps/webapp/app/utils/json.ts create mode 100644 apps/webapp/app/utils/objects.ts diff --git a/.changeset/cuddly-ligers-reflect.md b/.changeset/cuddly-ligers-reflect.md new file mode 100644 index 00000000000..5b45f5ba958 --- /dev/null +++ b/.changeset/cuddly-ligers-reflect.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added support for delaying delivery when sending custom events diff --git a/apps/docs/functions/send-event.mdx b/apps/docs/functions/send-event.mdx index bb299ac2151..9c11909a576 100644 --- a/apps/docs/functions/send-event.mdx +++ b/apps/docs/functions/send-event.mdx @@ -31,7 +31,7 @@ curl --request POST \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ - "id": "", + "id": "", "event": { "name": "user.created", "payload": { @@ -48,12 +48,154 @@ If you are calling this from inside a workflow, ensure that the first parameter ## Sending events from other workflows -It is useful to send events from workflows because you can split your logic into smaller chunks and reuse them. +It is useful to send events from workflows because you can split your logic into smaller chunks and reuse them. The below example shows how a scheduled workflow delegates the work via sending a custom event: -## Writing a workflow that is triggered by an event + -You should read [the documentation on custom events](/triggers/custom-events) to learn how to write a workflow that is triggered by an event. +```ts check-scheduler.ts +new Trigger({ + id: "check-scheduler", + name: "Check Scheduler", + on: scheduleEvent({ rateOf: { minutes: 10 } }), + run: async (event, context) => { + await context.sendEvent("health.check trigger.dev", { + name: "health.check", + payload: { + url: "https://trigger.dev", + host: "trigger.dev", + }, + }); + await context.sendEvent("health.check docs.trigger.dev", { + name: "health.check", + payload: { + url: "https://docs.trigger.dev", + host: "docs.trigger.dev", + }, + }); + + await context.sendEvent("health.check app.trigger.dev", { + name: "health.check", + payload: { + url: "https://app.trigger.dev/healthcheck", + host: "app.trigger.dev", + }, + }); + }, +}).listen(); +``` + +```ts health-check.ts +export const healthCheck = new Trigger({ + id: "health-check", + name: "Health Check", + on: customEvent({ + name: "health.check", + schema: z.object({ + url: z.string().url(), + host: z.string(), + }), + }), + run: async (event, context) => { + const response = await context.fetch("fetch site", event.url, { + method: "GET", + retry: { + enabled: false, + }, + }); + + if (response.ok) { + await context.logger.info(`${event.host} is up!`); + return; + } + + await slack.postMessage("Site is down", { + channelName: "health-checks", + text: `${event.host} is down: ${response.status}`, + }); + }, +}).listen(); +``` + + + +## Delaying event delivery + +Through both our Node.js SDK and our HTTP API, you can delay the delivery of an event. This is useful if you want to send an event to trigger a workflow, but you want to wait for a certain amount of time before the event is delivered. + +Using the health check example above, we can delay the delivery of the `health.check` event in various ways: + +```ts +new Trigger({ + id: "check-scheduler", + name: "Check Scheduler", + on: scheduleEvent({ rateOf: { minutes: 10 } }), + run: async (event, context) => { + await context.sendEvent("health.check trigger.dev", { + name: "health.check", + payload: { + url: "https://trigger.dev", + host: "trigger.dev", + }, + delay: { until: new Date(Date.now() + 1000 * 60 * 5) }, // Delay until a specific date + }); + + await context.sendEvent("health.check docs.trigger.dev", { + name: "health.check", + payload: { + url: "https://docs.trigger.dev", + host: "docs.trigger.dev", + }, + delay: { minutes: 5 }, // Delay for a specific amount of time + }); + + await context.sendEvent("health.check app.trigger.dev", { + name: "health.check", + payload: { + url: "https://app.trigger.dev/healthcheck", + host: "app.trigger.dev", + }, + delay: { seconds: 30 }, // Delay for a specific amount of time + }); + }, +}).listen(); ``` + + When sending events in the context of a workflow run, adding a delay DOES NOT + add a delay to the workflow run, as sending a custom event is a + fire-and-forget action. + + +You can add delays via the HTTP API by adding a `delay` object to the event: + +```bash +curl --request POST \ + --url https://app.trigger.dev/api/v1/events \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "id": "", + "event": { + "name": "user.created", + "payload": { + "userId": "123456" + }, + "delay": { + "seconds": 30 + } + } + }' +``` + +## Event deduplication + +When sending events, you can specify an `id` for the event. If you send an event with the same `id` multiple times, only the first event will be delivered. This is useful if you want to ensure that an event is only delivered once, even if you send it multiple times. + +```ts +sendEvent({ + id: "my-event-id", + name: "my-event", + payload: { foo: "bar" }, +}); ``` diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 41e403a370c..9fa5c4b2284 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -111,7 +111,8 @@ "reference/trigger", "reference/custom-event", "reference/webhook-event", - "reference/schedule-event" + "reference/schedule-event", + "reference/send-event" ] }, { diff --git a/apps/docs/reference/send-event.mdx b/apps/docs/reference/send-event.mdx new file mode 100644 index 00000000000..06c931d9f0f --- /dev/null +++ b/apps/docs/reference/send-event.mdx @@ -0,0 +1,97 @@ +--- +title: "sendEvent" +sidebarTitle: "sendEvent" +description: "Send a custom event to Trigger, from inside or outside of a workflow." +--- + +## Usage + +```ts +import { Trigger, customEvent } from "@trigger.dev/sdk"; + +new Trigger({ + id: "usage", + name: "usage", + on: customEvent({ + name: "user.created", + schema: z.any(), + }), + run: async (event, ctx) => { + await ctx.sendEvent("Sending context user.created event", { + name: "user.created", + payload: { + id: "1234_abc", + }, + }); + }, +}).listen(); +``` + +You can also use `sendEvent` by importing it from `@trigger.dev/sdk`: + +```ts +import { sendEvent } from "@trigger.dev/sdk"; + +await sendEvent("Sending context user.created event", { + name: "user.created", + payload: { + id: "1234_abc", + }, +}); +``` + + + If calling `sendEvent` from outside of a workflow run, it will make an HTTP + API request to app.trigger.dev to send the event. Make sure you set the + `TRIGGER_API_KEY` environment variable if you will be using it this way. + + +## Parameters + + + A unique key for the event in the context of a workflow run. Please see the + [Keys and Resumability](/guides/resumability) guide for more info. + + + + + + An optional unique ID for the event. If not provided, one will be + generated automatically (using `clid`). Set this field to perform event + deduplication. + + + + The name of the event. This is the name you set when creating the + `customEvent` trigger. + + + + The payload of the event. + + + + An optional timestamp for the event. If not provided, one will be generated. Must be in ISO 8601 format (e.g. `new Date().toISOString()`) + + + + An optional context object for the event. This can be used to pass + additional information about the event, such as the user who triggered + it. Note that this is not currently exposed to the workflow (coming soon). + + + + An optional delay object for the event. This can be used to delay the + event by a certain amount of time. Use one of the following options: + + - `{ seconds: number }` - delay the event by a certain number of seconds + - `{ minutes: number }` - delay the event by a certain number of minutes + - `{ hours: number }` - delay the event by a certain number of hours + - `{ days: number }` - delay the event by a certain number of days + - `{ until: Date }` - delay the event until a certain date + + See the [Delaying Event Delivery](/functions/send-event#delaying-event-delivery) docs for more info. + + + + diff --git a/apps/webapp/app/models/workflowRun.server.ts b/apps/webapp/app/models/workflowRun.server.ts index 77c5d976b68..123c6c9fb7a 100644 --- a/apps/webapp/app/models/workflowRun.server.ts +++ b/apps/webapp/app/models/workflowRun.server.ts @@ -1,14 +1,16 @@ -import type { WorkflowRun, WorkflowRunStep } from ".prisma/client"; -import type { WorkflowRunStatus } from ".prisma/client"; +import type { + WorkflowRun, + WorkflowRunStatus, + WorkflowRunStep, +} from ".prisma/client"; import type { CustomEventSchema, ErrorSchema, LogMessageSchema, } from "@trigger.dev/common-schemas"; -import { ulid } from "ulid"; import type { z } from "zod"; import { prisma } from "~/db.server"; -import { IngestEvent } from "~/services/events/ingest.server"; +import { IngestCustomEvent } from "~/services/events/ingestCustomEvent.server"; import { createStepOnce } from "./workflowRunStep.server"; export type { WorkflowRun, WorkflowRunStep, WorkflowRunStatus }; @@ -160,20 +162,12 @@ export async function triggerEventInRun( return; } - const ingestService = new IngestEvent(); - - await ingestService.call( - { - id: ulid(), - name: event.name, - type: "CUSTOM_EVENT", - service: "trigger", - payload: event.payload, - context: event.context, - apiKey: workflowRun.environment.apiKey, - }, - workflowRun.environment.organization - ); + const ingestService = new IngestCustomEvent(); + + await ingestService.call({ + apiKey: workflowRun.environment.apiKey, + event, + }); await prisma.workflowRunStep.update({ where: { id: step.step.id }, diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx index 6e144aff881..65b3c33ab9d 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/runs/$runId.tsx @@ -9,10 +9,10 @@ import { } from "@heroicons/react/24/outline"; import { ArrowPathRoundedSquareIcon, + ChatBubbleOvalLeftEllipsisIcon, CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon, - ChatBubbleOvalLeftEllipsisIcon, } from "@heroicons/react/24/solid"; import { useFetcher } from "@remix-run/react"; import type { LoaderArgs } from "@remix-run/server-runtime"; @@ -623,6 +623,40 @@ function CustomEventStep({ event }: { event: StepType }) { {event.input.name} + {"delay" in event.input && event.input.delay && ( + <> + + Delay + + + {"seconds" in event.input.delay ? ( + <> + {event.input.delay.seconds}{" "} + {event.input.delay.seconds > 1 ? "seconds" : "second"} + + ) : "minutes" in event.input.delay ? ( + <> + {event.input.delay.minutes}{" "} + {event.input.delay.minutes > 1 ? "minutes" : "minute"} + + ) : "hours" in event.input.delay ? ( + <> + {event.input.delay.hours}{" "} + {event.input.delay.hours > 1 ? "hours" : "hour"} + + ) : "days" in event.input.delay ? ( + <> + {event.input.delay.days}{" "} + {event.input.delay.days > 1 ? "days" : "day"} + + ) : "until" in event.input.delay ? ( + <>Until {event.input.delay.until} + ) : ( + <> + )} + + + )} Payload {event.input.context && ( diff --git a/apps/webapp/app/routes/api/v1/events.ts b/apps/webapp/app/routes/api/v1/events.ts index 062d6aaf949..7f1ac0c42ce 100644 --- a/apps/webapp/app/routes/api/v1/events.ts +++ b/apps/webapp/app/routes/api/v1/events.ts @@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime"; import { CustomEventSchema } from "@trigger.dev/common-schemas"; import { z } from "zod"; import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { IngestEvent } from "~/services/events/ingest.server"; +import { IngestCustomEvent } from "~/services/events/ingestCustomEvent.server"; const EventBodySchema = z.object({ id: z.string(), @@ -32,20 +32,13 @@ export async function action({ request }: ActionArgs) { return json({ error: eventBody.error.message }, { status: 400 }); } - const service = new IngestEvent(); - - const result = await service.call( - { - id: eventBody.data.id, - name: eventBody.data.event.name, - type: "CUSTOM_EVENT", - service: "trigger", - payload: eventBody.data.event.payload, - context: eventBody.data.event.context, - apiKey: authenticatedEnv.apiKey, - }, - authenticatedEnv.organization - ); - - return json(result.data); + const service = new IngestCustomEvent(); + + await service.call({ + id: eventBody.data.id, + event: eventBody.data.event, + apiKey: authenticatedEnv.apiKey, + }); + + return { status: 200 }; } diff --git a/apps/webapp/app/routes/resources/run/$organizationSlug/test/$workflowSlug.ts b/apps/webapp/app/routes/resources/run/$organizationSlug/test/$workflowSlug.ts index 30cd55775e0..9ddc914c4ef 100644 --- a/apps/webapp/app/routes/resources/run/$organizationSlug/test/$workflowSlug.ts +++ b/apps/webapp/app/routes/resources/run/$organizationSlug/test/$workflowSlug.ts @@ -13,6 +13,7 @@ import { import { getWorkflowFromSlugs } from "~/models/workflow.server"; import { CreateWorkflowTestRun } from "~/services/runs/createTestRun.server"; import { requireUserId } from "~/services/session.server"; +import { safeJsonParse } from "~/utils/json"; const requestSchema = z.object({ eventName: z.string(), @@ -39,7 +40,15 @@ export const action = async ({ request, params }: ActionArgs) => { const body = Object.fromEntries(formData.entries()); const { eventName, payload, source } = requestSchema.parse(body); - const jsonPayload = JSON.parse(payload); + const jsonPayload = safeJsonParse(payload); + + if (!jsonPayload) { + return redirectWithErrorMessage( + redirectUriForSource(source, organizationSlug, workflowSlug), + request, + "Invalid JSON payload" + ); + } const workflow = await getWorkflowFromSlugs({ userId, @@ -98,6 +107,19 @@ export const action = async ({ request, params }: ActionArgs) => { } }; +function redirectUriForSource( + source: "rerun" | "test", + organizationSlug: string, + workflowSlug: string, + runId?: string +) { + if (source === "rerun") { + return `/orgs/${organizationSlug}/workflows/${workflowSlug}/runs/${runId}`; + } else { + return `/orgs/${organizationSlug}/workflows/${workflowSlug}/test`; + } +} + function errorMessageForSource(source: "rerun" | "test") { if (source === "rerun") { return "Unable to rerun this workflow. Please contact help@trigger.dev for assistance."; diff --git a/apps/webapp/app/services/events/ingestCustomEvent.server.ts b/apps/webapp/app/services/events/ingestCustomEvent.server.ts new file mode 100644 index 00000000000..06673e7762c --- /dev/null +++ b/apps/webapp/app/services/events/ingestCustomEvent.server.ts @@ -0,0 +1,60 @@ +import type { CustomEventSchema } from "@trigger.dev/common-schemas"; +import type { PublishOptions } from "internal-platform"; +import { ulid } from "ulid"; +import type { z } from "zod"; +import { taskQueue } from "~/services/messageBroker.server"; +import { omit } from "~/utils/objects"; +import { IngestEvent } from "./ingest.server"; + +export type IngestCustomEventOptions = { + id?: string; + apiKey: string; + event: z.infer; + isTest?: boolean; +}; + +export class IngestCustomEvent { + public async call(options: IngestCustomEventOptions) { + if (options.event.delay) { + const deliveryOptions: PublishOptions = + "until" in options.event.delay + ? { deliverAt: new Date(options.event.delay.until).getTime() } + : "seconds" in options.event.delay + ? { deliverAfter: options.event.delay.seconds * 1000 } + : "minutes" in options.event.delay + ? { deliverAfter: options.event.delay.minutes * 60 * 1000 } + : "hours" in options.event.delay + ? { deliverAfter: options.event.delay.hours * 60 * 60 * 1000 } + : "days" in options.event.delay + ? { deliverAfter: options.event.delay.days * 60 * 60 * 24 * 1000 } + : { deliverAfter: 0 }; + + await taskQueue.publish( + "INGEST_DELAYED_EVENT", + { + id: options.id, + apiKey: options.apiKey, + event: omit(options.event, ["delay"]), + }, + {}, + deliveryOptions + ); + + return; + } + + const ingestService = new IngestEvent(); + + await ingestService.call({ + id: options.id ?? ulid(), + name: options.event.name, + type: "CUSTOM_EVENT", + service: "trigger", + payload: options.event.payload, + context: options.event.context, + timestamp: options.event.timestamp, + apiKey: options.apiKey, + isTest: options.isTest, + }); + } +} diff --git a/apps/webapp/app/services/messageBroker.server.ts b/apps/webapp/app/services/messageBroker.server.ts index ddff46a03e6..1d4f04cc161 100644 --- a/apps/webapp/app/services/messageBroker.server.ts +++ b/apps/webapp/app/services/messageBroker.server.ts @@ -35,7 +35,7 @@ import { InitiateDelay } from "./delays/initiateDelay.server"; import { ResolveDelay } from "./delays/resolveDelay.server"; import { sendEmail } from "./email.server"; import { DispatchEvent } from "./events/dispatch.server"; -import { IngestEvent } from "./events/ingest.server"; +import { IngestCustomEvent } from "./events/ingestCustomEvent.server"; import { HandleNewServiceConnection } from "./externalServices/handleNewConnection.server"; import { RegisterExternalSource } from "./externalSources/registerExternalSource.server"; import { CreateFetchRequest } from "./fetches/createFetchRequest.server"; @@ -51,6 +51,7 @@ import { WorkflowRunDisconnected } from "./runs/runDisconnected.server"; import { WorkflowRunTriggerTimeout } from "./runs/runTriggerTimeout.server"; import { DeliverScheduledEvent } from "./scheduler/deliverScheduledEvent.server"; import { RegisterSchedulerSource } from "./scheduler/registerSchedulerSource.server"; +import { omit } from "~/utils/objects"; let pulsarClient: PulsarClient; let triggerPublisher: ZodPublisher; @@ -374,6 +375,14 @@ const taskQueueCatalog = { data: z.object({ id: z.string() }), properties: z.object({}), }, + INGEST_DELAYED_EVENT: { + data: z.object({ + id: z.string().optional(), + apiKey: z.string(), + event: CustomEventSchema.omit({ delay: true }), + }), + properties: z.object({}), + }, TRIGGER_WORKFLOW_RUN: { data: z.object({ id: z.string() }), properties: z.object({}), @@ -646,6 +655,17 @@ function createTaskQueue() { return true; }, + INGEST_DELAYED_EVENT: async (id, data, properties, attributes) => { + if (attributes.redeliveryCount >= 4) { + return true; + } + + const ingestService = new IngestCustomEvent(); + + await ingestService.call(data); + + return true; + }, TRIGGER_WORKFLOW_RUN: async (id, data, properties) => { const run = await findWorklowRunById(data.id); @@ -690,19 +710,19 @@ function createTaskQueue() { return true; } - const service = new IngestEvent(); + if (!env.INTERNAL_TRIGGER_API_KEY) { + return true; + } - const result = await service.call({ + const service = new IngestCustomEvent(); + + await service.call({ id: data.id, - name: data.name, - type: "CUSTOM_EVENT", - service: "trigger", - payload: data.payload, - context: data.context, + event: omit(data, ["id"]), apiKey: env.INTERNAL_TRIGGER_API_KEY, }); - return result.status === "success"; + return true; }, }, }); diff --git a/apps/webapp/app/utils/json.ts b/apps/webapp/app/utils/json.ts new file mode 100644 index 00000000000..2da50949c96 --- /dev/null +++ b/apps/webapp/app/utils/json.ts @@ -0,0 +1,7 @@ +export function safeJsonParse(json: string): unknown { + try { + return JSON.parse(json); + } catch (e) { + return null; + } +} diff --git a/apps/webapp/app/utils/objects.ts b/apps/webapp/app/utils/objects.ts new file mode 100644 index 00000000000..337fba9cac6 --- /dev/null +++ b/apps/webapp/app/utils/objects.ts @@ -0,0 +1,14 @@ +export function omit, K extends keyof T>( + obj: T, + keys: K[] +): Omit { + const result: any = {}; + + for (const key of Object.keys(obj)) { + if (!keys.includes(key as K)) { + result[key] = obj[key]; + } + } + + return result; +} diff --git a/examples/smoke-test/src/index.ts b/examples/smoke-test/src/index.ts index 62093a8b1cf..c082e487164 100644 --- a/examples/smoke-test/src/index.ts +++ b/examples/smoke-test/src/index.ts @@ -23,11 +23,13 @@ const trigger = new Trigger({ await ctx.sendEvent("start-fire", { name: "smoke.test", payload: { baz: "banana" }, + delay: { until: new Date(Date.now() + 1000 * 60) }, }); await sendEvent("start-fire-2", { name: "smoke.test2", payload: { baz: "banana2" }, + delay: { minutes: 1 }, }); return { foo: "bar" }; @@ -36,6 +38,24 @@ const trigger = new Trigger({ trigger.listen(); +new Trigger({ + id: "smoke-test", + name: "Smoke Test", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: customEvent({ + name: "smoke.test", + schema: z.object({ baz: z.string() }), + }), + run: async (event, ctx) => { + await ctx.logger.info("Inside the smoke test workflow, received event", { + event, + myDate: new Date(), + }); + }, +}).listen(); + new Trigger({ id: "log-tests", name: "My logs", diff --git a/packages/common-schemas/src/events.ts b/packages/common-schemas/src/events.ts index 5ece387271e..37fff268ec6 100644 --- a/packages/common-schemas/src/events.ts +++ b/packages/common-schemas/src/events.ts @@ -6,6 +6,15 @@ export const CustomEventSchema = z.object({ payload: JsonSchema, context: JsonSchema.optional(), timestamp: z.string().datetime().optional(), + delay: z + .union([ + z.object({ seconds: z.number().int() }), + z.object({ minutes: z.number().int() }), + z.object({ hours: z.number().int() }), + z.object({ days: z.number().int() }), + z.object({ until: z.string().datetime() }), + ]) + .optional(), }); export const SerializableCustomEventSchema = z.object({ @@ -13,6 +22,15 @@ export const SerializableCustomEventSchema = z.object({ payload: SerializableJsonSchema, context: SerializableJsonSchema.optional(), timestamp: z.string().datetime().optional(), + delay: z + .union([ + z.object({ seconds: z.number().int() }), + z.object({ minutes: z.number().int() }), + z.object({ hours: z.number().int() }), + z.object({ days: z.number().int() }), + z.object({ until: z.date() }), + ]) + .optional(), }); const EventMatcherSchema = z.union([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d061b2ea1b0..b5ab3ac8ccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,33 +35,6 @@ importers: devDependencies: mintlify: 2.0.16 - apps/internal-triggers: - specifiers: - '@trigger.dev/integrations': ^0.1.13 - '@trigger.dev/sdk': ^0.2.10 - '@trigger.dev/tsconfig': workspace:* - '@types/node': '16' - '@types/pg': ^8.6.6 - dotenv: ^16.0.3 - pg: ^8.8.0 - rimraf: ^3.0.2 - tsup: ^6.5.0 - typescript: ^4.9.4 - zod: ^3.20.2 - dependencies: - '@trigger.dev/integrations': 0.1.13 - '@trigger.dev/sdk': 0.2.10 - pg: 8.8.0 - zod: 3.20.2 - devDependencies: - '@trigger.dev/tsconfig': link:../../config-packages/tsconfig - '@types/node': 16.18.11 - '@types/pg': 8.6.6 - dotenv: 16.0.3 - rimraf: 3.0.2 - tsup: 6.5.0_typescript@4.9.4 - typescript: 4.9.4 - apps/webapp: specifiers: '@aws-sdk/client-s3': ^3.186.0 @@ -5412,48 +5385,6 @@ packages: engines: {node: '>= 6'} dev: true - /@trigger.dev/integrations/0.1.13: - resolution: {integrity: sha512-2CfQNGqdC82om8Zr+PAdZZWov5SPAAAuVGV39d/MTNFLHhJZmJx47nbc5nnT/ujPSkoRrvOolwl93rVeceYoYA==} - dependencies: - '@react-email/render': 0.0.3 - '@trigger.dev/providers': 0.1.7 - '@trigger.dev/sdk': 0.2.10 - zod: 3.20.2 - transitivePeerDependencies: - - bufferutil - - encoding - - react - - supports-color - - utf-8-validate - dev: false - - /@trigger.dev/providers/0.1.7: - resolution: {integrity: sha512-wy7bh/bOGNgYbpUOJz59BkFU/0e3U0p7/5rClYY52QgAXZ9Z6c4JfocCqmYRxh+/Lb18N/wUd+vo5V9YfoyeIQ==} - dependencies: - '@shopify/admin-graphql-api-utilities': 2.0.1 - tiny-invariant: 1.3.1 - zod: 3.20.2 - dev: false - - /@trigger.dev/sdk/0.2.10: - resolution: {integrity: sha512-768mZmXi2iLgUfRIKOlpU3q9wRF6anhSnKoToJnRRzyAX3o6Kw5GkBXyM2QVuB5rt6psgbD+M4RKDaTnLpn/wQ==} - dependencies: - debug: 4.3.4 - evt: 2.4.13 - node-fetch: 2.6.7 - slug: 6.1.0 - ulid: 2.3.0 - uuid: 9.0.0 - ws: 8.12.0 - zod: 3.20.2 - zod-error: 1.1.0 - transitivePeerDependencies: - - bufferutil - - encoding - - supports-color - - utf-8-validate - dev: false - /@tsconfig/node10/1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -5724,14 +5655,6 @@ packages: /@types/normalize-package-data/2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} - /@types/pg/8.6.6: - resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==} - dependencies: - '@types/node': 18.11.18 - pg-protocol: 1.5.0 - pg-types: 2.2.0 - dev: true - /@types/prismjs/1.26.0: resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} dev: true @@ -6837,11 +6760,6 @@ packages: resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} engines: {node: '>=0.10'} - /buffer-writer/2.0.0: - resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} - engines: {node: '>=4'} - dev: false - /buffer/5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} dependencies: @@ -13195,10 +13113,6 @@ packages: semver: 6.3.0 dev: true - /packet-reader/1.0.0: - resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} - dev: false - /pako/0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: true @@ -13357,59 +13271,6 @@ packages: is-reference: 3.0.1 dev: true - /pg-connection-string/2.5.0: - resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} - dev: false - - /pg-int8/1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - /pg-pool/3.5.2_pg@8.8.0: - resolution: {integrity: sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==} - peerDependencies: - pg: '>=8.0' - dependencies: - pg: 8.8.0 - dev: false - - /pg-protocol/1.5.0: - resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==} - - /pg-types/2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.0 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - - /pg/8.8.0: - resolution: {integrity: sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - dependencies: - buffer-writer: 2.0.0 - packet-reader: 1.0.0 - pg-connection-string: 2.5.0 - pg-pool: 3.5.2_pg@8.8.0 - pg-protocol: 1.5.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - dev: false - - /pgpass/1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - dependencies: - split2: 4.1.0 - dev: false - /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -13563,24 +13424,6 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 - /postgres-array/2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - /postgres-bytea/1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} - engines: {node: '>=0.10.0'} - - /postgres-date/1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - /postgres-interval/1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - dependencies: - xtend: 4.0.2 - /posthog-js/1.39.4: resolution: {integrity: sha512-Elpf1gwyuObueXi89iH+9pP+WhpkiivP8Qwej4RzOLwSTa7Floaa4rgAw7rnCnX1PtRoJ3F0kqb6q9T+aZjRiA==} dependencies: @@ -15112,11 +14955,6 @@ packages: through: 2.3.8 dev: true - /split2/4.1.0: - resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==} - engines: {node: '>= 10.x'} - dev: false - /sprintf-js/1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} From 057a13b52c7e93f52456584e2bd492a5fc1f4269 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 27 Jan 2023 15:55:55 +0000 Subject: [PATCH 21/39] Slack integration improvements - Add ability to trigger workflows on block actions - Add reactions - Add ability to respond to a block action interaction --- .../app/components/triggers/triggerLabel.tsx | 2 + .../models/workflowListPresenter.server.ts | 56 ++++- .../internal/webhooks/slack/interactivity.ts | 19 ++ .../app/services/events/dispatch.server.ts | 20 +- .../performIntegrationRequest.server.ts | 3 + .../slack/handleInteractivity.server.ts | 73 ++++++ .../workflows/registerWorkflow.server.ts | 40 +++- .../migration.sql | 2 + .../migration.sql | 33 +++ apps/webapp/prisma/schema.prisma | 39 +++ examples/send-to-slack/src/index.ts | 135 ++++++++++- packages/common-schemas/src/events.ts | 9 + packages/common-schemas/src/triggers.ts | 18 +- .../src/slack/requests.ts | 225 +++++++++++++++++- packages/internal-integrations/src/types.ts | 1 + .../src/integrations/slack/events.ts | 37 +++ .../src/integrations/slack/index.ts | 64 +++++ .../src/providers/slack/index.ts | 1 + .../src/providers/slack/interactivity.ts | 76 +----- .../src/providers/slack/prodmanifest.json | 6 +- .../src/providers/slack/schemas.ts | 37 +++ 21 files changed, 810 insertions(+), 86 deletions(-) create mode 100644 apps/webapp/app/services/slack/handleInteractivity.server.ts create mode 100644 apps/webapp/prisma/migrations/20230127121743_add_slack_interaction_trigger_type/migration.sql create mode 100644 apps/webapp/prisma/migrations/20230127123917_add_internal_source_model/migration.sql create mode 100644 packages/trigger-integrations/src/integrations/slack/events.ts diff --git a/apps/webapp/app/components/triggers/triggerLabel.tsx b/apps/webapp/app/components/triggers/triggerLabel.tsx index 07f1bca99e3..5558fcb046d 100644 --- a/apps/webapp/app/components/triggers/triggerLabel.tsx +++ b/apps/webapp/app/components/triggers/triggerLabel.tsx @@ -12,6 +12,8 @@ export function triggerLabel(type: TriggerType) { return "HTTP endpoint"; case "SCHEDULE": return "Scheduled"; + case "SLACK_INTERACTION": + return "Slack interaction"; default: return type; } diff --git a/apps/webapp/app/models/workflowListPresenter.server.ts b/apps/webapp/app/models/workflowListPresenter.server.ts index 11831225574..956610654b7 100644 --- a/apps/webapp/app/models/workflowListPresenter.server.ts +++ b/apps/webapp/app/models/workflowListPresenter.server.ts @@ -1,5 +1,8 @@ -import type { SchedulerSource } from ".prisma/client"; -import { ScheduleSourceSchema } from "@trigger.dev/common-schemas"; +import type { SchedulerSource, InternalSource } from ".prisma/client"; +import { + ScheduleSourceSchema, + SlackInteractionSourceSchema, +} from "@trigger.dev/common-schemas"; import cronstrue from "cronstrue"; import type { DisplayProperties } from "internal-integrations"; import { github } from "internal-integrations"; @@ -60,7 +63,8 @@ export class WorkflowListPresenter { trigger: triggerProperties( workflow, workflow.externalSource ?? undefined, - workflow.schedulerSources[0] ?? undefined + workflow.schedulerSources[0] ?? undefined, + workflow.internalSources[0] ?? undefined ), integrations: { source: workflow.service @@ -105,6 +109,17 @@ function getWorkflows( orderBy: { createdAt: "desc" }, take: 1, }, + internalSources: { + select: { + source: true, + type: true, + }, + where: { + environmentId, + }, + orderBy: { createdAt: "desc" }, + take: 1, + }, runs: { select: { finishedAt: true, @@ -124,7 +139,8 @@ function getWorkflows( function triggerProperties( workflow: Pick, externalSource?: Pick, - schedulerSource?: Pick + schedulerSource?: Pick, + internalSource?: Pick ): { type: Workflow["type"]; typeTitle: string; @@ -157,7 +173,14 @@ function triggerProperties( }; } case "SCHEDULE": { - invariant(schedulerSource, "Scheduler source is required for schedule"); + if (!schedulerSource) { + return { + type: workflow.type, + typeTitle: "Schedule", + title: "Not configured", + }; + } + const source = ScheduleSourceSchema.parse(schedulerSource.schedule); if ("rateOf" in source) { @@ -205,6 +228,29 @@ function triggerProperties( typeTitle: "Custom event", title: `on: ${workflow.eventNames.join(", ")}`, }; + case "SLACK_INTERACTION": { + if (!internalSource) { + return { + type: workflow.type, + typeTitle: "Slack interaction", + title: "on: Slack interaction", + }; + } + + const slackSource = SlackInteractionSourceSchema.parse( + internalSource.source + ); + + return { + type: workflow.type, + typeTitle: "Slack interaction", + title: `block_id = ${slackSource.blockId}`, + properties: + slackSource.actionIds.length > 0 + ? [{ key: "Action ID", value: slackSource.actionIds.join(", ") }] + : undefined, + }; + } default: { return { type: workflow.type, diff --git a/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts b/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts index c9798e7de10..f3c988ef7e2 100644 --- a/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts +++ b/apps/webapp/app/routes/api/v1/internal/webhooks/slack/interactivity.ts @@ -1,5 +1,24 @@ import type { ActionArgs } from "@remix-run/server-runtime"; +import { HandleSlackInteractivity } from "~/services/slack/handleInteractivity.server"; export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + + const payload = formData.get("payload"); + + if (typeof payload !== "string") { + return { status: 400 }; + } + + const parsedPayload = JSON.parse(payload); + + const service = new HandleSlackInteractivity(); + + try { + await service.call(parsedPayload); + } catch (error) { + console.error(error); + } + return { status: 200 }; } diff --git a/apps/webapp/app/services/events/dispatch.server.ts b/apps/webapp/app/services/events/dispatch.server.ts index 7aa3366a96a..f547d2efca6 100644 --- a/apps/webapp/app/services/events/dispatch.server.ts +++ b/apps/webapp/app/services/events/dispatch.server.ts @@ -127,14 +127,22 @@ class EventMatcher { } function patternMatches(payload: any, pattern: any): boolean { - for (const [key, value] of Object.entries(pattern)) { - if (Array.isArray(value)) { - if (!value.includes(payload[key])) { + for (const [patternKey, patternValue] of Object.entries(pattern)) { + const payloadValue = payload[patternKey]; + + if (Array.isArray(patternValue)) { + if (patternValue.length > 0 && !patternValue.includes(payloadValue)) { return false; } - } else if (typeof value === "object") { - if (!patternMatches(payload[key], value)) { - return false; + } else if (typeof patternValue === "object") { + if (Array.isArray(payloadValue)) { + if (!payloadValue.some((item) => patternMatches(item, patternValue))) { + return false; + } + } else { + if (!patternMatches(payloadValue, patternValue)) { + return false; + } } } } diff --git a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts index 320042e1fd5..0c2275dfc9b 100644 --- a/apps/webapp/app/services/requests/performIntegrationRequest.server.ts +++ b/apps/webapp/app/services/requests/performIntegrationRequest.server.ts @@ -231,6 +231,7 @@ export class PerformIntegrationRequest { endpoint: integrationRequest.endpoint, params: integrationRequest.params, cache, + metadata: { requestId: integrationRequest.id }, }); } case "shopify": { @@ -239,6 +240,7 @@ export class PerformIntegrationRequest { endpoint: integrationRequest.endpoint, params: integrationRequest.params, cache, + metadata: { requestId: integrationRequest.id }, }); } case "resend": { @@ -247,6 +249,7 @@ export class PerformIntegrationRequest { endpoint: integrationRequest.endpoint, params: integrationRequest.params, cache, + metadata: { requestId: integrationRequest.id }, }); } default: { diff --git a/apps/webapp/app/services/slack/handleInteractivity.server.ts b/apps/webapp/app/services/slack/handleInteractivity.server.ts new file mode 100644 index 00000000000..1d3b55c38a2 --- /dev/null +++ b/apps/webapp/app/services/slack/handleInteractivity.server.ts @@ -0,0 +1,73 @@ +import { slack } from "@trigger.dev/providers"; +import { ulid } from "ulid"; +import { generateErrorMessage } from "zod-error"; +import type { PrismaClient } from "~/db.server"; +import { prisma } from "~/db.server"; +import { IngestEvent } from "../events/ingest.server"; + +export class HandleSlackInteractivity { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(payload: unknown) { + console.log("payload", JSON.stringify(payload, null, 2)); + + const parsedPayload = slack.schemas.blockAction.safeParse(payload); + + if (!parsedPayload.success) { + console.error( + "Invalid payload", + generateErrorMessage(parsedPayload.error.issues) + ); + + return; + } + + if (parsedPayload.data.type !== "block_actions") { + return; + } + + if (!parsedPayload.data.message) { + return; + } + + if (!parsedPayload.data.message.metadata) { + return; + } + + const { requestId } = parsedPayload.data.message.metadata.event_payload; + + const integrationRequest = + await this.#prismaClient.integrationRequest.findUnique({ + where: { id: requestId }, + include: { + run: { + include: { + environment: true, + workflow: true, + }, + }, + }, + }); + + if (!integrationRequest) { + return; + } + + const ingestService = new IngestEvent(); + + await ingestService.call({ + id: ulid(), + type: "SLACK_INTERACTION", + name: "block.action", + service: "slack", + payload: parsedPayload.data, + apiKey: integrationRequest.run.environment.apiKey, + }); + + return true; + } +} diff --git a/apps/webapp/app/services/workflows/registerWorkflow.server.ts b/apps/webapp/app/services/workflows/registerWorkflow.server.ts index 750819cec73..90da212f76b 100644 --- a/apps/webapp/app/services/workflows/registerWorkflow.server.ts +++ b/apps/webapp/app/services/workflows/registerWorkflow.server.ts @@ -45,12 +45,23 @@ export class RegisterWorkflow { } if (validation.data.trigger.service !== "trigger") { - await this.#upsertExternalSource( + const source = await this.upsertSource( validation.data, organization, workflow, environment ); + + if (source && source.status === "READY") { + await this.#prismaClient.workflow.update({ + where: { + id: workflow.id, + }, + data: { + status: "READY", + }, + }); + } } await this.#upsertEventRule( @@ -155,7 +166,7 @@ export class RegisterWorkflow { return workflow; } - async #upsertExternalSource( + async upsertSource( payload: WorkflowMetadata, organization: Organization, workflow: Workflow, @@ -220,6 +231,31 @@ export class RegisterWorkflow { return schedulerSource; } + case "SLACK_INTERACTION": { + if (!payload.trigger.source) { + return; + } + + return this.#prismaClient.internalSource.upsert({ + where: { + workflowId_environmentId: { + workflowId: workflow.id, + environmentId: environment.id, + }, + }, + update: { + source: payload.trigger.source, + }, + create: { + organizationId: organization.id, + workflowId: workflow.id, + environmentId: environment.id, + source: payload.trigger.source, + status: "READY", + type: "SLACK", + }, + }); + } default: { return; } diff --git a/apps/webapp/prisma/migrations/20230127121743_add_slack_interaction_trigger_type/migration.sql b/apps/webapp/prisma/migrations/20230127121743_add_slack_interaction_trigger_type/migration.sql new file mode 100644 index 00000000000..e9b9eceef04 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230127121743_add_slack_interaction_trigger_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TriggerType" ADD VALUE 'SLACK_INTERACTION'; diff --git a/apps/webapp/prisma/migrations/20230127123917_add_internal_source_model/migration.sql b/apps/webapp/prisma/migrations/20230127123917_add_internal_source_model/migration.sql new file mode 100644 index 00000000000..2bbe8c1cba7 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230127123917_add_internal_source_model/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "InternalSourceType" AS ENUM ('SLACK'); + +-- CreateEnum +CREATE TYPE "InternalSourceStatus" AS ENUM ('CREATED', 'READY', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "InternalSource" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "workflowId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "type" "InternalSourceType" NOT NULL, + "source" JSONB NOT NULL, + "status" "InternalSourceStatus" NOT NULL DEFAULT 'CREATED', + "readyAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InternalSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InternalSource_workflowId_environmentId_key" ON "InternalSource"("workflowId", "environmentId"); + +-- AddForeignKey +ALTER TABLE "InternalSource" ADD CONSTRAINT "InternalSource_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InternalSource" ADD CONSTRAINT "InternalSource_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InternalSource" ADD CONSTRAINT "InternalSource_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma index a3b0319619e..ca286c50ced 100644 --- a/apps/webapp/prisma/schema.prisma +++ b/apps/webapp/prisma/schema.prisma @@ -52,6 +52,7 @@ model Organization { externalSources ExternalSource[] eventRules EventRule[] schedulerSources SchedulerSource[] + internalSources InternalSource[] } model APIConnection { @@ -107,6 +108,7 @@ model RuntimeEnvironment { runs WorkflowRun[] eventRules EventRule[] schedulerSources SchedulerSource[] + internalSources InternalSource[] @@unique([organizationId, slug]) } @@ -134,6 +136,7 @@ model Workflow { rules EventRule[] externalServices ExternalService[] schedulerSources SchedulerSource[] + internalSources InternalSource[] service String @default("trigger") eventNames String[] @@ -181,6 +184,7 @@ enum TriggerType { HTTP_ENDPOINT EVENT_BRIDGE HTTP_POLLING + SLACK_INTERACTION } enum WorkflowStatus { @@ -257,6 +261,41 @@ enum SchedulerSourceStatus { CANCELLED } +// We should eventually move SchedulerSource to this as it's more generic +model InternalSource { + id String @id @default(cuid()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workflowId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + type InternalSourceType + source Json + + status InternalSourceStatus @default(CREATED) + + readyAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([workflowId, environmentId]) +} + +enum InternalSourceType { + SLACK +} + +enum InternalSourceStatus { + CREATED + READY + CANCELLED +} + model ExternalService { id String @id @default(cuid()) slug String diff --git a/examples/send-to-slack/src/index.ts b/examples/send-to-slack/src/index.ts index 2efec4c8eba..19c70079ac5 100644 --- a/examples/send-to-slack/src/index.ts +++ b/examples/send-to-slack/src/index.ts @@ -17,10 +17,6 @@ new Trigger({ }), }), run: async (event, ctx) => { - await ctx.logger.info( - "Received domain.created event, waiting for 1 minutes..." - ); - const response = await slack.postMessage("send-to-slack", { channelName: "test-integrations", text: `New domain created: ${event.domain} by customer ${event.customerId} cc @Eric #general`, @@ -142,3 +138,134 @@ function getRandomQuote() { return arnoldQuotes[Math.floor(Math.random() * arnoldQuotes.length)]; } + +const BLOCK_ID = "issue.action.block"; + +new Trigger({ + id: "slack-interactivity", + name: "Testing Slack Interactivity", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: customEvent({ + name: "slack.interact", + schema: z.any(), + }), + run: async (event, ctx) => { + await slack.postMessage("send-to-slack", { + channelName: "test-integrations", + text: `Click on one of the buttons:`, + blocks: [ + { + type: "actions", + block_id: BLOCK_ID, + elements: [ + { + type: "button", + action_id: "close_issue_123", + text: { + type: "plain_text", + text: "Close Issue", + emoji: true, + }, + value: "close_issue", + }, + { + type: "button", + action_id: "comment_issue_123", + text: { + type: "plain_text", + text: "Comment", + emoji: true, + }, + value: "comment_issue", + }, + { + type: "button", + action_id: "view_issue_123", + text: { + type: "plain_text", + text: "View Issue", + emoji: true, + }, + value: "view_issue", + url: "https://github.com/triggerdotdev/trigger.dev/issues/11", + }, + ], + }, + ], + }); + }, +}).listen(); + +const BLOCK_ID_2 = "release.action.block"; + +new Trigger({ + id: "slack-block-interaction", + name: "Slack Block Interaction", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: slack.events.blockActionInteraction({ + blockId: BLOCK_ID, + actionId: ["comment_issue_123", "close_issue_123"], + }), + run: async (event, ctx) => { + await slack.postMessageResponse("Response to user", event.response_url, { + text: `Thanks for clicking on the button!`, + replace_original: false, + }); + + await slack.postMessageResponse("Respond to everyone", event.response_url, { + text: `Now what do you want to do about the PR review?`, + replace_original: false, + response_type: "in_channel", + blocks: [ + { + type: "actions", + block_id: BLOCK_ID_2, + elements: [ + { + type: "button", + action_id: "submit_review_123", + text: { + type: "plain_text", + text: "Submit Review", + emoji: true, + }, + value: "submit_review", + }, + { + type: "button", + action_id: "close_review_123", + text: { + type: "plain_text", + text: "Close Review", + emoji: true, + }, + value: "close_review", + }, + ], + }, + ], + }); + }, +}).listen(); + +new Trigger({ + id: "slack-block-interaction-2", + name: "Slack Block Interaction 2", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: slack.events.blockActionInteraction({ + blockId: BLOCK_ID_2, + }), + run: async (event, ctx) => { + await slack.addReaction("React to message", { + name: "thumbsup", + timestamp: event.message.ts, + channelId: event.channel.id, + }); + }, +}).listen(); diff --git a/packages/common-schemas/src/events.ts b/packages/common-schemas/src/events.ts index 37fff268ec6..b64e0d08948 100644 --- a/packages/common-schemas/src/events.ts +++ b/packages/common-schemas/src/events.ts @@ -89,3 +89,12 @@ export const ManualWebhookSourceSchema = z.object({ }), event: z.string(), }); + +export const SlackInteractionSourceSchema = z.object({ + blockId: z.string(), + actionIds: z.array(z.string()), +}); + +export type SlackInteractionSource = z.infer< + typeof SlackInteractionSourceSchema +>; diff --git a/packages/common-schemas/src/triggers.ts b/packages/common-schemas/src/triggers.ts index 539adffa4fe..67fefdafc80 100644 --- a/packages/common-schemas/src/triggers.ts +++ b/packages/common-schemas/src/triggers.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import { EventFilterSchema, ScheduleSourceSchema } from "./events"; +import { + EventFilterSchema, + ScheduleSourceSchema, + SlackInteractionSourceSchema, +} from "./events"; import { JsonSchema } from "./json"; export const CustomEventTriggerSchema = z.object({ @@ -36,11 +40,23 @@ export const ScheduledEventTriggerSchema = z.object({ }); export type ScheduledEventTrigger = z.infer; +export const SlackInteractionTriggerSchema = z.object({ + type: z.literal("SLACK_INTERACTION"), + service: z.literal("slack"), + name: z.string(), + filter: EventFilterSchema, + source: SlackInteractionSourceSchema, +}); +export type SlackInteractionEventTrigger = z.infer< + typeof SlackInteractionTriggerSchema +>; + export const TriggerMetadataSchema = z.discriminatedUnion("type", [ CustomEventTriggerSchema, WebhookEventTriggerSchema, HttpEventTriggerSchema, ScheduledEventTriggerSchema, + SlackInteractionTriggerSchema, ]); export type TriggerMetadata = z.infer; diff --git a/packages/internal-integrations/src/slack/requests.ts b/packages/internal-integrations/src/slack/requests.ts index e4c9a4b7792..163bd77a6eb 100644 --- a/packages/internal-integrations/src/slack/requests.ts +++ b/packages/internal-integrations/src/slack/requests.ts @@ -18,6 +18,9 @@ const log = debug("trigger:integrations:slack"); const SendSlackMessageRequestBodySchema = slack.schemas.PostMessageBodySchema.extend({ link_names: z.literal(1), + metadata: z + .object({ event_type: z.string(), event_payload: z.any() }) + .optional(), }); class SlackRequestIntegration implements RequestIntegration { @@ -45,6 +48,15 @@ class SlackRequestIntegration implements RequestIntegration { path: "/chat.postMessage", }); + #addReactionEndpoint = new HttpEndpoint< + typeof slack.schemas.AddReactionResponseSchema, + typeof slack.schemas.AddReactionOptionsSchema + >({ + response: slack.schemas.AddReactionResponseSchema, + method: "POST", + path: "/reactions.add", + }); + constructor(private readonly baseUrl: string = "https://slack.com/api") {} perform(options: PerformRequestOptions): Promise { @@ -53,7 +65,24 @@ class SlackRequestIntegration implements RequestIntegration { return this.#postMessage( options.accessInfo, options.params, - options.cache + 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: { @@ -77,6 +106,24 @@ class SlackRequestIntegration implements RequestIntegration { ], }; } + 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}`); } @@ -90,7 +137,8 @@ class SlackRequestIntegration implements RequestIntegration { async #postMessage( accessInfo: AccessInfo, params: any, - cache?: CacheService + cache?: CacheService, + metadata?: Record ): Promise { const parsedParams = slack.schemas.PostMessageOptionsSchema.parse(params); @@ -127,6 +175,9 @@ class SlackRequestIntegration implements RequestIntegration { ...parsedParams, link_names: 1, channel: channelId, + metadata: metadata + ? { event_type: "post_message", event_payload: metadata } + : undefined, }); if (!response.success) { @@ -185,6 +236,172 @@ class SlackRequestIntegration implements RequestIntegration { return performedRequest; } + async #addReaction( + accessInfo: AccessInfo, + params: any, + cache?: CacheService, + metadata?: Record + ): Promise { + const parsedParams = slack.schemas.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: slack.schemas.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 || @@ -244,3 +461,7 @@ class SlackRequestIntegration implements RequestIntegration { } export const requests = new SlackRequestIntegration(); + +function safeGetJson(response: Response): Promise { + return response.json().catch(() => null); +} diff --git a/packages/internal-integrations/src/types.ts b/packages/internal-integrations/src/types.ts index c9006a3159b..68eba80be31 100644 --- a/packages/internal-integrations/src/types.ts +++ b/packages/internal-integrations/src/types.ts @@ -42,6 +42,7 @@ export type PerformRequestOptions = { endpoint: string; params: any; cache?: CacheService; + metadata?: Record; }; export type DisplayProperties = { diff --git a/packages/trigger-integrations/src/integrations/slack/events.ts b/packages/trigger-integrations/src/integrations/slack/events.ts new file mode 100644 index 00000000000..67dc5afb914 --- /dev/null +++ b/packages/trigger-integrations/src/integrations/slack/events.ts @@ -0,0 +1,37 @@ +import { slack } from "@trigger.dev/providers"; +import { TriggerEvent } from "@trigger.dev/sdk"; + +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: slack.schemas.blockAction, + }; +} diff --git a/packages/trigger-integrations/src/integrations/slack/index.ts b/packages/trigger-integrations/src/integrations/slack/index.ts index 1e457edcc3e..fe1fd2e2005 100644 --- a/packages/trigger-integrations/src/integrations/slack/index.ts +++ b/packages/trigger-integrations/src/integrations/slack/index.ts @@ -1,6 +1,9 @@ import { getTriggerRun } from "@trigger.dev/sdk"; import { z } from "zod"; import { slack } from "@trigger.dev/providers"; +import * as events from "./events"; + +export { events }; export type PostMessageOptions = z.infer< typeof slack.schemas.PostMessageOptionsSchema @@ -31,3 +34,64 @@ export async function postMessage( return output; } + +export type PostMessageResponseOptions = z.infer< + typeof slack.schemas.PostMessageResponseOptionsSchema +>; + +export type PostMessageResponseResponse = z.infer< + typeof slack.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: slack.schemas.PostMessageResponseSuccessResponseSchema, + }, + }); + + return output; +} + +export type AddReactionOptions = z.infer< + typeof slack.schemas.AddReactionOptionsSchema +>; + +export type AddReactionResponse = z.infer< + typeof slack.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: slack.schemas.AddReactionSuccessResponseSchema, + }, + }); + + return output; +} diff --git a/packages/trigger-providers/src/providers/slack/index.ts b/packages/trigger-providers/src/providers/slack/index.ts index e3149c0582c..51121adba63 100644 --- a/packages/trigger-providers/src/providers/slack/index.ts +++ b/packages/trigger-providers/src/providers/slack/index.ts @@ -16,6 +16,7 @@ export const slack = { "im:write", "mpim:write", "chat:write.customize", + "reactions:write", ], }, schemas, diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index 1bb3f018262..0a981c72c62 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { plainTextElementSchema } from "./blocks"; const blockActionType = z.union([ z.literal("block_actions"), @@ -23,7 +22,7 @@ export const blockAction = z.object({ channel_id: z.string(), is_ephemeral: z.boolean(), }), - trigger_id: z.string(), + trigger_id: z.string().optional(), team: z.object({ id: z.string(), domain: z.string() }), enterprise: z.null(), is_enterprise_install: z.boolean(), @@ -32,68 +31,18 @@ export const blockAction = z.object({ .object({ bot_id: z.string(), type: sourceType, - text: z.string(), - user: z.string(), + text: z.string().optional(), + user: z.string().optional(), ts: z.string(), - app_id: z.string(), - blocks: z.array( - z.union([ - z.object({ - type: z.string(), - block_id: z.string(), - text: z.object({ - type: z.string(), - text: z.string(), - verbatim: z.boolean(), - }), - }), - z.object({ type: z.string(), block_id: z.string() }), - z.object({ - type: z.string(), - block_id: z.string(), - text: z.object({ - type: z.string(), - text: z.string(), - verbatim: z.boolean(), - }), - accessory: z.object({ - type: z.string(), - image_url: z.string(), - alt_text: z.string(), - }), - }), - z.object({ - type: z.string(), - block_id: z.string(), - elements: z.array( - z.union([ - z.object({ - type: z.string(), - action_id: z.string(), - text: z.object({ - type: z.string(), - text: z.string(), - emoji: z.boolean(), - }), - value: z.string(), - }), - z.object({ - type: z.string(), - action_id: z.string(), - text: z.object({ - type: z.string(), - text: z.string(), - emoji: z.boolean(), - }), - value: z.string(), - url: z.string(), - }), - ]) - ), - }), - ]) - ), - team: z.string(), + app_id: z.string().optional(), + blocks: z.array(z.any()).optional(), + team: z.string().optional(), + metadata: z + .object({ + event_type: z.string(), + event_payload: z.object({ requestId: z.string() }), + }) + .optional(), }) .optional(), state: z.object({ values: z.object({}) }), @@ -102,7 +51,6 @@ export const blockAction = z.object({ z.object({ action_id: z.string(), block_id: z.string(), - text: plainTextElementSchema, value: z.string(), type: z.string(), action_ts: z.string(), diff --git a/packages/trigger-providers/src/providers/slack/prodmanifest.json b/packages/trigger-providers/src/providers/slack/prodmanifest.json index d67716a9202..2f63a247e93 100644 --- a/packages/trigger-providers/src/providers/slack/prodmanifest.json +++ b/packages/trigger-providers/src/providers/slack/prodmanifest.json @@ -18,10 +18,12 @@ "groups:read", "im:read", "mpim:read", - "chat:write" + "chat:write", + "reactions:write" ], "bot": [ - "chat:write" + "chat:write", + "reactions:write" ] } }, diff --git a/packages/trigger-providers/src/providers/slack/schemas.ts b/packages/trigger-providers/src/providers/slack/schemas.ts index 7e075949f9f..c1fe80518cd 100644 --- a/packages/trigger-providers/src/providers/slack/schemas.ts +++ b/packages/trigger-providers/src/providers/slack/schemas.ts @@ -1,5 +1,8 @@ import { z } from "zod"; import { knownBlockSchema } from "./blocks"; +import { blockAction } from "./interactivity"; + +export { blockAction }; export const PostMessageSuccessResponseSchema = z.object({ ok: z.literal(true), @@ -50,6 +53,13 @@ export const PostMessageOptionsSchema = z }) .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({ @@ -80,3 +90,30 @@ 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, +]); From e8a29dca6bcf2ab5202fa8627c2e2e293a973240 Mon Sep 17 00:00:00 2001 From: D-K-P Date: Fri, 27 Jan 2023 11:25:29 -0800 Subject: [PATCH 22/39] Added login and sign up buttons, changed logo to point to app.trigger.dev --- apps/docs/mint.json | 28 +++++++++++++++------------- apps/docs/welcome.mdx | 2 ++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 9fa5c4b2284..77f128cced6 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -3,7 +3,7 @@ "logo": { "dark": "/logo/light.png", "light": "/logo/light.png", - "href": "https://trigger.dev" + "href": "https://app.trigger.dev" }, "favicon": "/images/favicon.png", "colors": { @@ -20,6 +20,16 @@ "default": "dark", "isHidden": true }, + "topbarLinks": [ + { + "name": "Login", + "url": "https://app.trigger.dev" + }, + { + "name": "Sign up", + "url": "https://app.trigger.dev" + } + ], "topbarCtaButton": { "type": "github", "url": "https://github.com/triggerdotdev/trigger.dev" @@ -39,11 +49,7 @@ "navigation": [ { "group": "Getting Started", - "pages": [ - "welcome", - "getting-started", - "get-help" - ] + "pages": ["welcome", "getting-started", "get-help"] }, { "group": "Examples", @@ -71,15 +77,11 @@ "pages": [ { "group": "Slack", - "pages": [ - "integrations/apis/slack/actions/post-message" - ] + "pages": ["integrations/apis/slack/actions/post-message"] }, { "group": "Resend.com", - "pages": [ - "integrations/apis/resend/actions/send-email" - ] + "pages": ["integrations/apis/resend/actions/send-email"] } ] }, @@ -138,4 +140,4 @@ "github": "https://github.com/triggerdotdev/trigger.dev", "discord": "https://discord.gg/nkqV9xBYWy" } -} \ No newline at end of file +} diff --git a/apps/docs/welcome.mdx b/apps/docs/welcome.mdx index 816a75828ea..c723b28780f 100644 --- a/apps/docs/welcome.mdx +++ b/apps/docs/welcome.mdx @@ -8,6 +8,8 @@ Trigger.dev is an open source platform that enables developers to create event-d Workflows live in your codebase so you can use your existing types, functions, version control and IDE. They are triggered by us but run on your server so your private data is never exposed. +Go to [trigger.dev](https://app.trigger.dev) and sign up or login to your account. + _A workflow in action:_ ![Test a run](/images/workflow-demo.gif) From 74df0b702bba2804b05830c7d89f37a656ccc1c1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 12:57:40 -0800 Subject: [PATCH 23/39] Fix for example workflow when mesage is undefined --- examples/send-to-slack/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/send-to-slack/src/index.ts b/examples/send-to-slack/src/index.ts index 19c70079ac5..14cbdea4303 100644 --- a/examples/send-to-slack/src/index.ts +++ b/examples/send-to-slack/src/index.ts @@ -262,6 +262,11 @@ new Trigger({ blockId: BLOCK_ID_2, }), run: async (event, ctx) => { + if (!event.message) { + ctx.logger.debug(`No message found`); + return; + } + await slack.addReaction("React to message", { name: "thumbsup", timestamp: event.message.ts, From b75ef0266e15954d0f747d13523c480d0284874e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 12:57:47 -0800 Subject: [PATCH 24/39] Added schema for blocks --- .../trigger-providers/src/providers/slack/interactivity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index 0a981c72c62..d9204fc9f93 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { knownBlockSchema } from "./blocks"; const blockActionType = z.union([ z.literal("block_actions"), @@ -35,7 +36,7 @@ export const blockAction = z.object({ user: z.string().optional(), ts: z.string(), app_id: z.string().optional(), - blocks: z.array(z.any()).optional(), + blocks: z.array(knownBlockSchema).optional(), team: z.string().optional(), metadata: z .object({ From 9f935cdd3b7e9daf12cee1433851b79542038d1f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 13:44:00 -0800 Subject: [PATCH 25/39] Use jsx-slack in the Slack example --- examples/send-to-slack/package.json | 3 +- .../send-to-slack/src/{index.ts => index.tsx} | 66 ++++++---------- examples/send-to-slack/tsconfig.json | 8 +- pnpm-lock.yaml | 76 ++++++++++--------- 4 files changed, 72 insertions(+), 81 deletions(-) rename examples/send-to-slack/src/{index.ts => index.tsx} (86%) diff --git a/examples/send-to-slack/package.json b/examples/send-to-slack/package.json index 93f35229e02..1f261d4ee88 100644 --- a/examples/send-to-slack/package.json +++ b/examples/send-to-slack/package.json @@ -6,6 +6,7 @@ "dependencies": { "@trigger.dev/integrations": "workspace:*", "@trigger.dev/sdk": "workspace:*", + "jsx-slack": "^5.3.0", "zod": "^3.20.2" }, "devDependencies": { @@ -14,6 +15,6 @@ "tsx": "^3.12.0" }, "scripts": { - "dev": "tsx src/index.ts" + "dev": "tsx src/index.tsx" } } diff --git a/examples/send-to-slack/src/index.ts b/examples/send-to-slack/src/index.tsx similarity index 86% rename from examples/send-to-slack/src/index.ts rename to examples/send-to-slack/src/index.tsx index 14cbdea4303..befcaa83b93 100644 --- a/examples/send-to-slack/src/index.ts +++ b/examples/send-to-slack/src/index.tsx @@ -1,5 +1,8 @@ import { Trigger, customEvent } from "@trigger.dev/sdk"; import { slack } from "@trigger.dev/integrations"; +/** @jsxImportSource jsx-slack */ +import JSXSlack, { Actions, Blocks, Button, Section } from "jsx-slack"; +import { jsxslack } from "jsx-slack"; import { z } from "zod"; new Trigger({ @@ -152,48 +155,29 @@ new Trigger({ schema: z.any(), }), run: async (event, ctx) => { - await slack.postMessage("send-to-slack", { + await slack.postMessage("jsx-test", { channelName: "test-integrations", - text: `Click on one of the buttons:`, - blocks: [ - { - type: "actions", - block_id: BLOCK_ID, - elements: [ - { - type: "button", - action_id: "close_issue_123", - text: { - type: "plain_text", - text: "Close Issue", - emoji: true, - }, - value: "close_issue", - }, - { - type: "button", - action_id: "comment_issue_123", - text: { - type: "plain_text", - text: "Comment", - emoji: true, - }, - value: "comment_issue", - }, - { - type: "button", - action_id: "view_issue_123", - text: { - type: "plain_text", - text: "View Issue", - emoji: true, - }, - value: "view_issue", - url: "https://github.com/triggerdotdev/trigger.dev/issues/11", - }, - ], - }, - ], + text: "Test of jsx", + blocks: JSXSlack( + +
This is using jsxslack
+ + + + + +
+ ), }); }, }).listen(); diff --git a/examples/send-to-slack/tsconfig.json b/examples/send-to-slack/tsconfig.json index 00ad22f1a85..c441b3366ea 100644 --- a/examples/send-to-slack/tsconfig.json +++ b/examples/send-to-slack/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "@trigger.dev/tsconfig/examples.json", - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/*.test.*"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "**/*.test.*"], + "compilerOptions": { + "jsx": "react-jsx", // or "react-jsxdev" for development + "jsxImportSource": "jsx-slack" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5ab3ac8ccd..36d67f12f19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,7 +184,7 @@ importers: '@aws-sdk/client-s3': 3.245.0 '@aws-sdk/s3-request-presigner': 3.245.0 '@cfworker/json-schema': 1.12.5 - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/lang-javascript': 6.1.2 '@codemirror/lang-json': 6.0.1 @@ -211,7 +211,7 @@ importers: '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/providers': link:../../packages/trigger-providers '@trigger.dev/sdk': link:../../packages/trigger-sdk - '@uiw/react-codemirror': 4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle + '@uiw/react-codemirror': 4.19.5_aguurb4bmecpxzejz52amioxne bcryptjs: 2.4.3 classnames: 2.3.2 clsx: 1.2.1 @@ -508,11 +508,13 @@ importers: '@trigger.dev/sdk': 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 + jsx-slack: 5.3.0 zod: 3.20.2 devDependencies: '@trigger.dev/tsconfig': link:../../config-packages/tsconfig @@ -748,7 +750,7 @@ importers: tsup: ^6.5.0 zod: ^3.20.2 dependencies: - '@react-email/render': 0.0.3 + '@react-email/render': 0.0.3_react@18.2.0 '@trigger.dev/providers': link:../trigger-providers '@trigger.dev/sdk': link:../trigger-sdk zod: 3.20.2 @@ -3455,12 +3457,13 @@ packages: prettier: 2.8.2 dev: false - /@codemirror/autocomplete/6.4.0_czcfkg2f66rxeiodoti7r2gulu: + /@codemirror/autocomplete/6.4.0_eo6pz6bvsllvatnnwfprpuflde: resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==} peerDependencies: '@codemirror/language': ^6.0.0 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 + '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.3.2 '@codemirror/state': 6.2.0 @@ -3480,7 +3483,7 @@ packages: /@codemirror/lang-javascript/6.1.2: resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==} dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 '@codemirror/state': 6.2.0 @@ -4678,16 +4681,6 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: false - /@react-email/render/0.0.3: - resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==} - engines: {node: '>=18.0.0'} - dependencies: - pretty: 2.0.0 - react-dom: 18.2.0 - transitivePeerDependencies: - - react - dev: false - /@react-email/render/0.0.3_react@18.2.0: resolution: {integrity: sha512-+4eOrLGdTCJjoJU3PunekErjo3PFnhSDFjVINBHrfJT+1wlVdhWfDo7hFdzXJx/JOOYqmnZTilU/umGLdRumKQ==} engines: {node: '>=18.0.0'} @@ -4803,7 +4796,7 @@ packages: eslint: 8.31.0 eslint-import-resolver-node: 0.3.6 eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy - eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny + eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq eslint-plugin-jest: 26.9.0_ohsifnwenhmxgcp7mend4dnv74 eslint-plugin-jest-dom: 4.0.3_eslint@8.31.0 eslint-plugin-jsx-a11y: 6.7.1_eslint@8.31.0 @@ -5164,6 +5157,11 @@ packages: engines: {node: '>=10'} dev: true + /@slack/types/2.8.0: + resolution: {integrity: sha512-ghdfZSF0b4NC9ckBA8QnQgC9DJw2ZceDq0BIjjRSv6XAZBXJdWgxIsYz0TYnWSiqsKZGH2ZXbj9jYABZdH3OSQ==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + dev: false + /@swc/core-darwin-arm64/1.3.26: resolution: {integrity: sha512-FWWflBfKRYrUJtko2xiedC5XCa31O75IZZqnTWuLpe9g3C5tnUuF3M8LSXZS/dn6wprome1MhtG9GMPkSYkhkg==} engines: {node: '>=10'} @@ -5918,17 +5916,18 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@uiw/codemirror-extensions-basic-setup/4.19.5_wd2tsis3in55bkaiwnc2c46tom: + /@uiw/codemirror-extensions-basic-setup/4.19.5_tbeldtdcrf45b35pezgkzq2u4e: resolution: {integrity: sha512-1zt7ZPJ01xKkSW/KDy0FZNga0bngN1fC594wCVG7FBi60ehfcAucpooQ+JSPScKXopxcb+ugPKZvVLzr9/OfzA==} peerDependencies: '@codemirror/autocomplete': '>=6.0.0' '@codemirror/commands': '>=6.0.0' '@codemirror/language': '>=6.0.0' + '@codemirror/lint': '>=6.0.0' '@codemirror/search': '>=6.0.0' '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 @@ -5937,11 +5936,14 @@ packages: '@codemirror/view': 6.7.2 dev: false - /@uiw/react-codemirror/4.19.5_k4ec5g7vuuzzonc3d6xbjnmmle: + /@uiw/react-codemirror/4.19.5_aguurb4bmecpxzejz52amioxne: resolution: {integrity: sha512-ZCHh8d7beXbF8/t7F1+yHht6A9Y6CdKeOkZq4A09lxJEnyTQrj1FMf2zvfaqc7K23KNjkTCtSlbqKKbVDgrWaw==} peerDependencies: + '@babel/runtime': '>=7.11.0' '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: @@ -5950,13 +5952,14 @@ packages: '@codemirror/state': 6.2.0 '@codemirror/theme-one-dark': 6.1.0 '@codemirror/view': 6.7.2 - '@uiw/codemirror-extensions-basic-setup': 4.19.5_wd2tsis3in55bkaiwnc2c46tom - codemirror: 6.0.1 + '@uiw/codemirror-extensions-basic-setup': 4.19.5_tbeldtdcrf45b35pezgkzq2u4e + codemirror: 6.0.1_@lezer+common@1.0.2 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 transitivePeerDependencies: - '@codemirror/autocomplete' - '@codemirror/language' + - '@codemirror/lint' - '@codemirror/search' dev: false @@ -7267,16 +7270,18 @@ packages: engines: {node: '>=0.10.0'} dev: false - /codemirror/6.0.1: + /codemirror/6.0.1_@lezer+common@1.0.2: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} dependencies: - '@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu + '@codemirror/autocomplete': 6.4.0_eo6pz6bvsllvatnnwfprpuflde '@codemirror/commands': 6.1.3 '@codemirror/language': 6.3.2 '@codemirror/lint': 6.1.0 '@codemirror/search': 6.2.3 '@codemirror/state': 6.2.0 '@codemirror/view': 6.7.2 + transitivePeerDependencies: + - '@lezer/common' dev: false /collection-visit/1.0.0: @@ -8612,7 +8617,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.12.0 eslint: 8.31.0 - eslint-plugin-import: 2.27.4_2ac3tknkazjoq5fxmuugu665ny + eslint-plugin-import: 2.27.4_qdjeohovcytra7xto5vgmxssaq get-tsconfig: 4.3.0 globby: 13.1.3 is-core-module: 2.11.0 @@ -8622,7 +8627,7 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.4_v73lhamtbyinynmwa5fn7kpmfq: + /eslint-module-utils/2.7.4_sqt5xxn4ciiurbqrzlaarm6ama: resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} engines: {node: '>=4'} peerDependencies: @@ -8647,7 +8652,6 @@ packages: debug: 3.2.7 eslint: 8.31.0 eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.5.3_hnftvkj7qg3s6bbigj4pr6djxy transitivePeerDependencies: - supports-color dev: true @@ -8672,7 +8676,7 @@ packages: regexpp: 3.2.0 dev: true - /eslint-plugin-import/2.27.4_2ac3tknkazjoq5fxmuugu665ny: + /eslint-plugin-import/2.27.4_qdjeohovcytra7xto5vgmxssaq: resolution: {integrity: sha512-Z1jVt1EGKia1X9CnBCkpAOhWy8FgQ7OmJ/IblEkT82yrFU/xJaxwujaTzLWqigewwynRQ9mmHfX9MtAfhxm0sA==} engines: {node: '>=4'} peerDependencies: @@ -8690,7 +8694,7 @@ packages: doctrine: 2.1.0 eslint: 8.31.0 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4_v73lhamtbyinynmwa5fn7kpmfq + eslint-module-utils: 2.7.4_sqt5xxn4ciiurbqrzlaarm6ama has: 1.0.3 is-core-module: 2.11.0 is-glob: 4.0.3 @@ -11184,6 +11188,13 @@ packages: object.assign: 4.1.4 dev: true + /jsx-slack/5.3.0: + resolution: {integrity: sha512-xPYP8zLO31AKYiyuDJ88ykJzV/GvZUK+ktM4xE6ESIzrCICSd8nhrW/yaSyX45U+oD5LNTDUnhJvQe0LFDi63g==} + engines: {node: '>=14'} + dependencies: + '@slack/types': 2.8.0 + dev: false + /junk/3.1.0: resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==} engines: {node: '>=8'} @@ -13779,15 +13790,6 @@ packages: shallow-equal: 1.2.1 dev: false - /react-dom/18.2.0: - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} - peerDependencies: - react: ^18.2.0 - dependencies: - loose-envify: 1.4.0 - scheduler: 0.23.0 - dev: false - /react-dom/18.2.0_react@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: From ffacc2fb6d6ba0d6214865c0aa3526447607374c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 14:58:32 -0800 Subject: [PATCH 26/39] Added a static select dropdown and lots more schema work --- examples/send-to-slack/src/index.tsx | 44 ++++- .../src/providers/slack/interactivity.ts | 181 +++++++++++++----- 2 files changed, 178 insertions(+), 47 deletions(-) diff --git a/examples/send-to-slack/src/index.tsx b/examples/send-to-slack/src/index.tsx index befcaa83b93..f09508f837c 100644 --- a/examples/send-to-slack/src/index.tsx +++ b/examples/send-to-slack/src/index.tsx @@ -1,8 +1,13 @@ import { Trigger, customEvent } from "@trigger.dev/sdk"; import { slack } from "@trigger.dev/integrations"; -/** @jsxImportSource jsx-slack */ -import JSXSlack, { Actions, Blocks, Button, Section } from "jsx-slack"; -import { jsxslack } from "jsx-slack"; +import JSXSlack, { + Actions, + Blocks, + Button, + Section, + Select, + Option, +} from "jsx-slack"; import { z } from "zod"; new Trigger({ @@ -143,6 +148,7 @@ function getRandomQuote() { } const BLOCK_ID = "issue.action.block"; +const BLOCK_ID_RATING = "issue.rating.block"; new Trigger({ id: "slack-interactivity", @@ -176,6 +182,16 @@ new Trigger({ Comment +
+ Rate this experience + +
), }); @@ -236,6 +252,28 @@ new Trigger({ }, }).listen(); +new Trigger({ + id: "slack-block-interaction-rating", + name: "Slack get rating", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: slack.events.blockActionInteraction({ + blockId: BLOCK_ID_RATING, + actionId: ["rating"], + }), + run: async (event, ctx) => { + const ratingAction = event.actions.find((a) => a.action_id === "rating"); + + if (ratingAction?.type === "static_select") { + await slack.postMessageResponse("Response to user", event.response_url, { + text: `Thanks for rating ${ratingAction.selected_option.value}!`, + replace_original: false, + }); + } + }, +}).listen(); + new Trigger({ id: "slack-block-interaction-2", name: "Slack Block Interaction 2", diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index d9204fc9f93..254d259ab3b 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -1,5 +1,12 @@ import { z } from "zod"; -import { knownBlockSchema } from "./blocks"; +import { + blockSchema, + knownBlockSchema, + messageAttachmentSchema, + mrkdwnElementSchema, + plainTextElementSchema, + viewSchema, +} from "./blocks"; const blockActionType = z.union([ z.literal("block_actions"), @@ -8,53 +15,139 @@ const blockActionType = z.union([ const sourceType = z.literal("message"); -export const blockAction = z.object({ - type: blockActionType, - user: z.object({ - id: z.string(), - username: z.string(), - name: z.string(), - team_id: z.string(), - }), - api_app_id: z.string(), - container: z.object({ - type: sourceType, - message_ts: z.string(), - channel_id: z.string(), - is_ephemeral: z.boolean(), +const commonActionSchema = z.object({ + action_id: z.string(), + block_id: z.string(), + action_ts: z.string(), +}); + +const buttonAction = z.object({ + type: z.literal("button"), + value: z.string(), +}); + +const staticSelectAction = z.object({ + type: z.literal("static_select"), + selected_option: z.object({ + text: z.object({ + type: z.string(), + text: z.string(), + emoji: z.boolean().optional(), + }), + value: z.string(), }), - trigger_id: z.string().optional(), - team: z.object({ id: z.string(), domain: z.string() }), - enterprise: z.null(), - is_enterprise_install: z.boolean(), - channel: z.object({ id: z.string(), name: z.string() }), - message: z + placeholder: 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(), + type: z.string(), + text: z.string(), + emoji: z.boolean(), }) .optional(), - state: z.object({ values: z.object({}) }), - response_url: z.string(), - actions: z.array( - z.object({ - action_id: z.string(), - block_id: z.string(), - value: z.string(), - type: z.string(), - action_ts: z.string(), +}); + +const actionSchema = z + .discriminatedUnion("type", [buttonAction, staticSelectAction]) + .and(commonActionSchema); + +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(), +}); + +const stateSchema = z.object({ + values: z.record( + z.record( + z.object({ + type: z.string(), + text: z + .discriminatedUnion("type", [ + plainTextElementSchema, + mrkdwnElementSchema, + ]) + .optional(), + value: 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), +}); From 617cc221cbffc7080c517589e910d929924950ea Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 27 Jan 2023 15:11:59 -0800 Subject: [PATCH 27/39] Added new slack integration workflow type --- .../images/triggers/slack-interaction.png | Bin 0 -> 46640 bytes .../app/components/triggers/Trigger.tsx | 40 ++++++++++++++++++ .../app/components/triggers/TriggerIcons.tsx | 9 ++++ 3 files changed, 49 insertions(+) create mode 100644 apps/webapp/app/assets/images/triggers/slack-interaction.png diff --git a/apps/webapp/app/assets/images/triggers/slack-interaction.png b/apps/webapp/app/assets/images/triggers/slack-interaction.png new file mode 100644 index 0000000000000000000000000000000000000000..9ea87cfe05e4d936cf34aa6ad94e68aa8ac0d0fb GIT binary patch literal 46640 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%0A2!=6iGfa+DNUZoa@TcbcesG%D; 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 literal 0 HcmV?d00001 diff --git a/apps/webapp/app/components/triggers/Trigger.tsx b/apps/webapp/app/components/triggers/Trigger.tsx index b31d2f27561..2e45412f53d 100644 --- a/apps/webapp/app/components/triggers/Trigger.tsx +++ b/apps/webapp/app/components/triggers/Trigger.tsx @@ -3,6 +3,7 @@ import type { ScheduledEventTrigger, ScheduleSourceCron, ScheduleSourceRate, + SlackInteractionEventTrigger, TriggerMetadata, WebhookEventTrigger, } from "@trigger.dev/common-schemas"; @@ -20,6 +21,8 @@ export function TriggerBody({ trigger }: { trigger: TriggerMetadata }) { return ; case "HTTP_ENDPOINT": break; + case "SLACK_INTERACTION": + return ; default: break; } @@ -28,6 +31,43 @@ export function TriggerBody({ trigger }: { trigger: TriggerMetadata }) { const workflowNodeUppercaseClasses = "uppercase text-slate-400 tracking-wide"; +function SlackInteraction({ + trigger, +}: { + trigger: SlackInteractionEventTrigger; +}) { + return ( +
+
+ + Name + + + {trigger.name} + +
+
+ + Action + + + {trigger.source.actionIds} + +
+
+ + Block + + + {trigger.source.blockId} + +
+
+ ); +} + +// trigger.source.actionIds; + function Webhook({ webhook }: { webhook: WebhookEventTrigger }) { return ( <> diff --git a/apps/webapp/app/components/triggers/TriggerIcons.tsx b/apps/webapp/app/components/triggers/TriggerIcons.tsx index 044bbfa0d89..ea22a7ce871 100644 --- a/apps/webapp/app/components/triggers/TriggerIcons.tsx +++ b/apps/webapp/app/components/triggers/TriggerIcons.tsx @@ -5,6 +5,7 @@ import CustomEvent from "../../assets/images/triggers/custom-event.png"; import HttpEndpoint from "../../assets/images/triggers/http-endpoint.png"; import Schedule from "../../assets/images/triggers/schedule.png"; import Webhook from "../../assets/images/triggers/webhook.png"; +import SlackInteraction from "../../assets/images/triggers/slack-interaction.png"; import { triggerLabel } from "./triggerLabel"; type TriggerType = Workflow["type"]; @@ -52,6 +53,14 @@ export function TriggerTypeIcon({ return ( {triggerLabel(type)} ); + case "SLACK_INTERACTION": + return ( + {triggerLabel(type)} + ); default: return null; } From 75eca2895b2e27b4b827a55b2bf2947375c88702 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 15:15:14 -0800 Subject: [PATCH 28/39] Added more dropdown support --- .../src/providers/slack/interactivity.ts | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index 254d259ab3b..c33616cd6d8 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -1,8 +1,6 @@ import { z } from "zod"; import { - blockSchema, knownBlockSchema, - messageAttachmentSchema, mrkdwnElementSchema, plainTextElementSchema, viewSchema, @@ -45,9 +43,32 @@ const staticSelectAction = z.object({ .optional(), }); -const actionSchema = z - .discriminatedUnion("type", [buttonAction, staticSelectAction]) - .and(commonActionSchema); +const userSelectAction = z.object({ + type: z.literal("users_select"), + selected_user: z.string(), +}); + +const conversationsSelectAction = z.object({ + type: z.literal("conversations_select"), + selected_conversation: z.string(), +}); +const channelSelectAction = z.object({ + type: z.literal("channels_select"), + selected_channel: z.string(), +}); + +const possibleActionsSchema = z.discriminatedUnion("type", [ + buttonAction, + staticSelectAction, + userSelectAction, + conversationsSelectAction, + channelSelectAction, +]); +const actionSchema = possibleActionsSchema.and(commonActionSchema); + +const stateSchema = z.object({ + values: z.record(z.record(possibleActionsSchema)), +}); const userSchema = z.object({ id: z.string(), @@ -103,23 +124,6 @@ const messageActionSchema = z.object({ .optional(), }); -const stateSchema = z.object({ - values: z.record( - z.record( - z.object({ - type: z.string(), - text: z - .discriminatedUnion("type", [ - plainTextElementSchema, - mrkdwnElementSchema, - ]) - .optional(), - value: z.string().optional(), - }) - ) - ), -}); - export const blockAction: Zod.ZodObject<{ type: typeof blockActionType; user: typeof userSchema; From 128f3baa089c3a9bf2fe3dca976552ccd099748b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 15:25:03 -0800 Subject: [PATCH 29/39] Added way more action types --- .../src/providers/slack/interactivity.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index c33616cd6d8..dab3ce1ed2f 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -2,10 +2,15 @@ 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"), @@ -21,6 +26,7 @@ const commonActionSchema = z.object({ const buttonAction = z.object({ type: z.literal("button"), + text: textSchema.optional(), value: z.string(), }); @@ -46,15 +52,40 @@ const staticSelectAction = z.object({ const userSelectAction = z.object({ type: z.literal("users_select"), selected_user: z.string(), + initial_user: z.string().optional(), }); const conversationsSelectAction = z.object({ type: z.literal("conversations_select"), selected_conversation: z.string(), + initial_conversation: z.string().optional(), }); const channelSelectAction = z.object({ type: z.literal("channels_select"), selected_channel: z.string(), + initial_channel: z.string().optional(), +}); + +const datePickerAction = z.object({ + type: z.literal("datepicker"), + selected_date: z.string(), + 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(), + initial_time: z.string().optional(), }); const possibleActionsSchema = z.discriminatedUnion("type", [ @@ -63,6 +94,10 @@ const possibleActionsSchema = z.discriminatedUnion("type", [ userSelectAction, conversationsSelectAction, channelSelectAction, + datePickerAction, + checkboxesAction, + radioButtonsSchema, + timePickerSchema, ]); const actionSchema = possibleActionsSchema.and(commonActionSchema); From 65b78f8f202e4f6e17d1b1fa4db354c63004381a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 27 Jan 2023 15:41:34 -0800 Subject: [PATCH 30/39] Small fix for overflowing username/email address in menu --- apps/webapp/app/components/UserProfileMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/UserProfileMenu.tsx b/apps/webapp/app/components/UserProfileMenu.tsx index 52e73011419..2e02f22d2aa 100644 --- a/apps/webapp/app/components/UserProfileMenu.tsx +++ b/apps/webapp/app/components/UserProfileMenu.tsx @@ -23,7 +23,7 @@ export function UserProfileMenu({ user }: { user: User }) { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - + {user.name ? ( Date: Fri, 27 Jan 2023 16:23:11 -0800 Subject: [PATCH 31/39] Fixed links to quick start guide --- apps/webapp/app/components/CreateNewWorkflow.tsx | 2 +- apps/webapp/app/components/navigation/SideMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/CreateNewWorkflow.tsx b/apps/webapp/app/components/CreateNewWorkflow.tsx index f93612ba9a8..591f8337cea 100644 --- a/apps/webapp/app/components/CreateNewWorkflow.tsx +++ b/apps/webapp/app/components/CreateNewWorkflow.tsx @@ -62,7 +62,7 @@ export function CreateNewWorkflowNoWorkflows() {
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 35bb7b526f5..38a72e2f812 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -219,7 +219,7 @@ function SideMenu({
  • Date: Fri, 27 Jan 2023 16:29:56 -0800 Subject: [PATCH 32/39] Added contact to resend docs --- apps/docs/examples/resend.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/docs/examples/resend.mdx b/apps/docs/examples/resend.mdx index 8e3d744d306..36ce42ac255 100644 --- a/apps/docs/examples/resend.mdx +++ b/apps/docs/examples/resend.mdx @@ -4,6 +4,11 @@ sidebarTitle: "Resend" description: "Here are some example workflows you could create using the Resend integration." --- + + Resend is in private beta, if you would like access please contact us at{" "} + help@trigger.dev + + ## Welcome email drip campaign This workflow will get triggered and a Slack notification and welcom email will be sent straight away. From 96ff7f136cb1e144bdf516ff40345056657d8802 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:03:18 -0800 Subject: [PATCH 33/39] Added more interactive elements --- .../src/providers/slack/interactivity.ts | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index dab3ce1ed2f..78c8ccd4933 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -30,23 +30,23 @@ const buttonAction = z.object({ 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: z.object({ - text: z.object({ - type: z.string(), - text: z.string(), - emoji: z.boolean().optional(), - }), - value: z.string(), - }), - placeholder: z - .object({ - type: z.string(), - text: z.string(), - emoji: z.boolean(), - }) - .optional(), + selected_option: selectedOptionSchema, + placeholder: placeholderSchema.optional(), }); const userSelectAction = z.object({ @@ -88,6 +88,24 @@ const timePickerSchema = z.object({ 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, @@ -98,6 +116,9 @@ const possibleActionsSchema = z.discriminatedUnion("type", [ checkboxesAction, radioButtonsSchema, timePickerSchema, + plainTextInputSchema, + multiUsersSelectSchema, + multiStaticSelectSchema, ]); const actionSchema = possibleActionsSchema.and(commonActionSchema); From 2d72c112f34d2a1d1db1dad030a2655bcaa93c59 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:38:02 -0800 Subject: [PATCH 34/39] Fix for dropdown schema failure. Added nice example --- .../apis/slack/actions/post-message.mdx | 135 +++++++++++++++++- examples/send-to-slack/src/index.tsx | 135 ++++++++---------- .../src/providers/slack/interactivity.ts | 14 +- 3 files changed, 200 insertions(+), 84 deletions(-) diff --git a/apps/docs/integrations/apis/slack/actions/post-message.mdx b/apps/docs/integrations/apis/slack/actions/post-message.mdx index e1e9585a583..dad39860151 100644 --- a/apps/docs/integrations/apis/slack/actions/post-message.mdx +++ b/apps/docs/integrations/apis/slack/actions/post-message.mdx @@ -28,6 +28,14 @@ Publish slack messages to a public or private channel in your Slack Workspace as The formatted text of the message to be published, formatted as [mrkdwn](https://api.slack.com/reference/surfaces/formatting#basics). + + You can use blocks to create a rich message with images, buttons, and more. + + You can either pass in a JavaScript object or use [JSX Slack](https://github.com/yhatt/jsx-slack/) to create blocks. We highly recommend using JSX Slack, it's much easier to read and write. See the [block example](#) to see it in action. + + Full full detail on block see the [Slack API docs](https://api.slack.com/reference/block-kit/blocks). + + @@ -69,6 +77,8 @@ Publish slack messages to a public or private channel in your Slack Workspace as ## Example Workflows +### Notify Slack on New Star + ```typescript Notify Slack on New Star @@ -89,11 +99,130 @@ new Trigger({ }).listen(); ``` +```json Example response +{ + "ok": true, + "ts": "1673618429.084699", + "channel": "C04GWUTDC3W", + "message": { + "ts": "1673618429.084699", + "text": "New domain created: trigger.dev by customer 1st-ever-slack-workflow-yippie", + "type": "message", + "user": "U04JTTQ08SF", + "bot_id": "B04JR3DCB4M" + } +} +``` + -## Example Response +### Blocks with interactivity + + + +```typescript Blocks with interactivity +import { slack } from "@trigger.dev/integrations"; +import JSXSlack, { + Actions, + Blocks, + Button, + Section, + Select, + Option, +} from "jsx-slack"; +import { z } from "zod"; + +const BLOCK_ID = "issue.action.block"; + +//this workflow is triggered by sending a custom event +//it posts a Slack message with some buttons +new Trigger({ + id: "slack-interactivity", + name: "Testing Slack Interactivity", + on: customEvent({ + name: "slack.interact", + schema: z.any(), + }), + run: async (event, ctx) => { + await slack.postMessage("jsx-test", { + channelName: "test-integrations", + text: "Test of jsx", + blocks: JSXSlack( + +
    This is using jsxslack
    + + + + + +
    + ), + }); + }, +}).listen(); + +//this workflow receives the button click events +new Trigger({ + id: "slack-block-interaction", + name: "Slack Block Interaction", + on: slack.events.blockActionInteraction({ + blockId: BLOCK_ID, + actionId: ["comment_issue_123", "close_issue_123"], + }), + run: async (event, ctx) => { + await slack.postMessageResponse("Response to user", event.response_url, { + text: `Thanks for clicking on the button!`, + replace_original: false, + }); + + await slack.postMessageResponse("Respond to everyone", event.response_url, { + text: `Now what do you want to do about the PR review?`, + replace_original: false, + response_type: "in_channel", + blocks: [ + { + type: "actions", + block_id: BLOCK_ID_2, + elements: [ + { + type: "button", + action_id: "submit_review_123", + text: { + type: "plain_text", + text: "Submit Review", + emoji: true, + }, + value: "submit_review", + }, + { + type: "button", + action_id: "close_review_123", + text: { + type: "plain_text", + text: "Close Review", + emoji: true, + }, + value: "close_review", + }, + ], + }, + ], + }); + }, +}).listen(); +``` -```json +```json Example response { "ok": true, "ts": "1673618429.084699", @@ -107,3 +236,5 @@ new Trigger({ } } ``` + +
    diff --git a/examples/send-to-slack/src/index.tsx b/examples/send-to-slack/src/index.tsx index f09508f837c..3b361d1e3ae 100644 --- a/examples/send-to-slack/src/index.tsx +++ b/examples/send-to-slack/src/index.tsx @@ -1,4 +1,4 @@ -import { Trigger, customEvent } from "@trigger.dev/sdk"; +import { Trigger, customEvent, scheduleEvent } from "@trigger.dev/sdk"; import { slack } from "@trigger.dev/integrations"; import JSXSlack, { Actions, @@ -150,40 +150,38 @@ function getRandomQuote() { const BLOCK_ID = "issue.action.block"; const BLOCK_ID_RATING = "issue.rating.block"; +//every minute see how your employees are doing, we don't recommend this frequency 😉 new Trigger({ id: "slack-interactivity", name: "Testing Slack Interactivity", apiKey: "trigger_dev_zC25mKNn6c0q", endpoint: "ws://localhost:8889/ws", logLevel: "debug", - on: customEvent({ - name: "slack.interact", - schema: z.any(), + on: scheduleEvent({ + rateOf: { + minutes: 1, + }, }), run: async (event, ctx) => { await slack.postMessage("jsx-test", { channelName: "test-integrations", - text: "Test of jsx", + //text appears in Slack notifications on mobile/desktop + text: "How is your progress today?", + //import and use JSXSlack to make creating rich messages much easier blocks: JSXSlack( -
    This is using jsxslack
    +
    How is your progress today?
    - - - -
    - Rate this experience -
    +
    ), }); @@ -208,69 +206,54 @@ new Trigger({ logLevel: "debug", on: slack.events.blockActionInteraction({ blockId: BLOCK_ID, - actionId: ["comment_issue_123", "close_issue_123"], + actionId: ["status-blocked", "status-help", "rating"], }), run: async (event, ctx) => { - await slack.postMessageResponse("Response to user", event.response_url, { - text: `Thanks for clicking on the button!`, - replace_original: false, - }); + //create promises from all the actions + const promises = event.actions.map((action) => { + switch (action.action_id) { + case "status-blocked": { + //the user is blocked so add a 😢 emoji as a reaction + if (event.message) { + return slack.addReaction("React to message", { + name: "cry", + timestamp: event.message.ts, + channelId: event.channel.id, + }); + } + } + case "status-help": { + //the user needs help so add an 🆘 emoji as a reaction + if (event.message) { + return slack.addReaction("React to message", { + name: "sos", + timestamp: event.message.ts, + channelId: event.channel.id, + }); + } + } + case "rating": { + if (action.type != "static_select") { + throw new Error("This action should be a select"); + } - await slack.postMessageResponse("Respond to everyone", event.response_url, { - text: `Now what do you want to do about the PR review?`, - replace_original: false, - response_type: "in_channel", - blocks: [ - { - type: "actions", - block_id: BLOCK_ID_2, - elements: [ + //post the rating as a message that appears below the original, + //only the user pressing the button will see this message + return slack.postMessageResponse( + "Added a comment to the issue", + event.response_url, { - type: "button", - action_id: "submit_review_123", - text: { - type: "plain_text", - text: "Submit Review", - emoji: true, - }, - value: "submit_review", - }, - { - type: "button", - action_id: "close_review_123", - text: { - type: "plain_text", - text: "Close Review", - emoji: true, - }, - value: "close_review", - }, - ], - }, - ], + text: `You rated your day ${action.selected_option.value} stars`, + replace_original: false, + } + ); + } + default: + return Promise.resolve(); + } }); - }, -}).listen(); -new Trigger({ - id: "slack-block-interaction-rating", - name: "Slack get rating", - apiKey: "trigger_dev_zC25mKNn6c0q", - endpoint: "ws://localhost:8889/ws", - logLevel: "debug", - on: slack.events.blockActionInteraction({ - blockId: BLOCK_ID_RATING, - actionId: ["rating"], - }), - run: async (event, ctx) => { - const ratingAction = event.actions.find((a) => a.action_id === "rating"); - - if (ratingAction?.type === "static_select") { - await slack.postMessageResponse("Response to user", event.response_url, { - text: `Thanks for rating ${ratingAction.selected_option.value}!`, - replace_original: false, - }); - } + await Promise.all(promises); }, }).listen(); diff --git a/packages/trigger-providers/src/providers/slack/interactivity.ts b/packages/trigger-providers/src/providers/slack/interactivity.ts index 78c8ccd4933..2e341d605e3 100644 --- a/packages/trigger-providers/src/providers/slack/interactivity.ts +++ b/packages/trigger-providers/src/providers/slack/interactivity.ts @@ -45,30 +45,30 @@ const placeholderSchema = z.object({ }); const staticSelectAction = z.object({ type: z.literal("static_select"), - selected_option: selectedOptionSchema, + selected_option: selectedOptionSchema.nullable(), placeholder: placeholderSchema.optional(), }); const userSelectAction = z.object({ type: z.literal("users_select"), - selected_user: z.string(), + selected_user: z.string().nullable(), initial_user: z.string().optional(), }); const conversationsSelectAction = z.object({ type: z.literal("conversations_select"), - selected_conversation: z.string(), + selected_conversation: z.string().nullable(), initial_conversation: z.string().optional(), }); const channelSelectAction = z.object({ type: z.literal("channels_select"), - selected_channel: z.string(), + selected_channel: z.string().nullable(), initial_channel: z.string().optional(), }); const datePickerAction = z.object({ type: z.literal("datepicker"), - selected_date: z.string(), + selected_date: z.string().nullable(), initial_date: z.string().optional(), }); @@ -84,7 +84,7 @@ const radioButtonsSchema = z.object({ const timePickerSchema = z.object({ type: z.literal("timepicker"), - selected_time: z.string(), + selected_time: z.string().nullable(), initial_time: z.string().optional(), }); @@ -120,8 +120,10 @@ const possibleActionsSchema = z.discriminatedUnion("type", [ 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)), }); From dd62655bc7fd1475069e519c6afd8f907578f392 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:45:19 -0800 Subject: [PATCH 35/39] Added documentation for posting block Slack messages --- .../apis/slack/actions/post-message.mdx | 145 +++++++++--------- 1 file changed, 76 insertions(+), 69 deletions(-) diff --git a/apps/docs/integrations/apis/slack/actions/post-message.mdx b/apps/docs/integrations/apis/slack/actions/post-message.mdx index dad39860151..e2689e35f05 100644 --- a/apps/docs/integrations/apis/slack/actions/post-message.mdx +++ b/apps/docs/integrations/apis/slack/actions/post-message.mdx @@ -118,6 +118,15 @@ new Trigger({ ### Blocks with interactivity +You can add interaction and richer visual elements using Blocks. The easiest way to add them is using `jsx-slack` (which is a separate npm package that you can install). + +This example messages your team every minute (!) and asks them how they're doing. There are two buttons and a dropdown menu. + +In this examples there are two workflows: + +1. Messages your team every minute (!) and asks them how they're doing. +2. Receives the interaction from users. It adds reactions when the buttons are pressed and posts a message when a user selects from the dropdown menu. + ```typescript Blocks with interactivity @@ -134,36 +143,42 @@ import { z } from "zod"; const BLOCK_ID = "issue.action.block"; -//this workflow is triggered by sending a custom event -//it posts a Slack message with some buttons +//1. every minute see how your employees are doing, we don't recommend this frequency 😉 new Trigger({ id: "slack-interactivity", name: "Testing Slack Interactivity", - on: customEvent({ - name: "slack.interact", - schema: z.any(), + on: scheduleEvent({ + rateOf: { + minutes: 1, + }, }), run: async (event, ctx) => { await slack.postMessage("jsx-test", { channelName: "test-integrations", - text: "Test of jsx", + //text appears in Slack notifications on mobile/desktop + text: "How is your progress today?", + //import and use JSXSlack to make creating rich messages much easier blocks: JSXSlack( -
    This is using jsxslack
    +
    How is your progress today?
    - - +
    ), @@ -171,70 +186,62 @@ new Trigger({ }, }).listen(); -//this workflow receives the button click events +//2. this workflow listens for Slack interactions filtered by the block id and actions we used above new Trigger({ id: "slack-block-interaction", name: "Slack Block Interaction", on: slack.events.blockActionInteraction({ blockId: BLOCK_ID, - actionId: ["comment_issue_123", "close_issue_123"], + actionId: ["status-blocked", "status-help", "rating"], }), run: async (event, ctx) => { - await slack.postMessageResponse("Response to user", event.response_url, { - text: `Thanks for clicking on the button!`, - replace_original: false, - }); - - await slack.postMessageResponse("Respond to everyone", event.response_url, { - text: `Now what do you want to do about the PR review?`, - replace_original: false, - response_type: "in_channel", - blocks: [ - { - type: "actions", - block_id: BLOCK_ID_2, - elements: [ - { - type: "button", - action_id: "submit_review_123", - text: { - type: "plain_text", - text: "Submit Review", - emoji: true, - }, - value: "submit_review", - }, + //create promises from all the actions + const promises = event.actions.map((action) => { + switch (action.action_id) { + case "status-blocked": { + //the user is blocked so add a 😢 emoji as a reaction + if (event.message) { + return slack.addReaction("React to message", { + name: "cry", + timestamp: event.message.ts, + channelId: event.channel.id, + }); + } + } + case "status-help": { + //the user needs help so add an 🆘 emoji as a reaction + if (event.message) { + return slack.addReaction("React to message", { + name: "sos", + timestamp: event.message.ts, + channelId: event.channel.id, + }); + } + } + case "rating": { + if (action.type != "static_select") { + throw new Error("This action should be a select"); + } + + //post the rating as a message that appears below the original, + //only the user pressing the button will see this message + return slack.postMessageResponse( + "Added a comment to the issue", + event.response_url, { - type: "button", - action_id: "close_review_123", - text: { - type: "plain_text", - text: "Close Review", - emoji: true, - }, - value: "close_review", - }, - ], - }, - ], + text: `You rated your day ${action.selected_option?.value} stars`, + replace_original: false, + } + ); + } + default: + return Promise.resolve(); + } }); + + await Promise.all(promises); }, }).listen(); ``` -```json Example response -{ - "ok": true, - "ts": "1673618429.084699", - "channel": "C04GWUTDC3W", - "message": { - "ts": "1673618429.084699", - "text": "New domain created: trigger.dev by customer 1st-ever-slack-workflow-yippie", - "type": "message", - "user": "U04JTTQ08SF", - "bot_id": "B04JR3DCB4M" - } -} -``` -
    From f5b88ce272005aeeb8a6103e4e192ffb2296f8f3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:46:47 -0800 Subject: [PATCH 36/39] Deal with optional in Slack example --- examples/send-to-slack/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/send-to-slack/src/index.tsx b/examples/send-to-slack/src/index.tsx index 3b361d1e3ae..08dbcb62b7d 100644 --- a/examples/send-to-slack/src/index.tsx +++ b/examples/send-to-slack/src/index.tsx @@ -243,7 +243,7 @@ new Trigger({ "Added a comment to the issue", event.response_url, { - text: `You rated your day ${action.selected_option.value} stars`, + text: `You rated your day ${action.selected_option?.value} stars`, replace_original: false, } ); From e1b26c863d8d1c7bdb0ca5ae0f76e3e1c0ffb0c8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:48:07 -0800 Subject: [PATCH 37/39] Improved the Slack interaction trigger block style --- apps/webapp/app/components/triggers/Trigger.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/triggers/Trigger.tsx b/apps/webapp/app/components/triggers/Trigger.tsx index 2e45412f53d..fd790f196ec 100644 --- a/apps/webapp/app/components/triggers/Trigger.tsx +++ b/apps/webapp/app/components/triggers/Trigger.tsx @@ -37,7 +37,7 @@ function SlackInteraction({ trigger: SlackInteractionEventTrigger; }) { return ( -
    +
    Name @@ -48,18 +48,18 @@ function SlackInteraction({
    - Action + Block - {trigger.source.actionIds} + {trigger.source.blockId}
    - Block + Action - {trigger.source.blockId} + {trigger.source.actionIds.join(", ")}
    From b29041002c38733bce04ad25356b9ac063ba00a0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:49:23 -0800 Subject: [PATCH 38/39] Changeset added: Slack blocks support --- .changeset/large-dryers-fix.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/large-dryers-fix.md diff --git a/.changeset/large-dryers-fix.md b/.changeset/large-dryers-fix.md new file mode 100644 index 00000000000..0db50e68738 --- /dev/null +++ b/.changeset/large-dryers-fix.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/integrations": patch +"@trigger.dev/providers": patch +"@trigger.dev/sdk": patch +--- + +Slack blocks support From 28cc99bf9f998ed56373a0aa5e3f8e19303b2ffc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jan 2023 16:49:49 -0800 Subject: [PATCH 39/39] Changeset version: Slack blocks support --- .changeset/cuddly-ligers-reflect.md | 5 ----- .changeset/large-dryers-fix.md | 7 ------- packages/trigger-integrations/CHANGELOG.md | 10 ++++++++++ packages/trigger-integrations/package.json | 2 +- packages/trigger-providers/CHANGELOG.md | 6 ++++++ packages/trigger-providers/package.json | 2 +- packages/trigger-sdk/CHANGELOG.md | 7 +++++++ packages/trigger-sdk/package.json | 2 +- 8 files changed, 26 insertions(+), 15 deletions(-) delete mode 100644 .changeset/cuddly-ligers-reflect.md delete mode 100644 .changeset/large-dryers-fix.md diff --git a/.changeset/cuddly-ligers-reflect.md b/.changeset/cuddly-ligers-reflect.md deleted file mode 100644 index 5b45f5ba958..00000000000 --- a/.changeset/cuddly-ligers-reflect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Added support for delaying delivery when sending custom events diff --git a/.changeset/large-dryers-fix.md b/.changeset/large-dryers-fix.md deleted file mode 100644 index 0db50e68738..00000000000 --- a/.changeset/large-dryers-fix.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@trigger.dev/integrations": patch -"@trigger.dev/providers": patch -"@trigger.dev/sdk": patch ---- - -Slack blocks support diff --git a/packages/trigger-integrations/CHANGELOG.md b/packages/trigger-integrations/CHANGELOG.md index 1ee237b061a..37e8682b3f0 100644 --- a/packages/trigger-integrations/CHANGELOG.md +++ b/packages/trigger-integrations/CHANGELOG.md @@ -1,5 +1,15 @@ # @trigger.dev/integrations +## 0.1.15 + +### Patch Changes + +- b290410: Slack blocks support +- Updated dependencies [52d21ac] +- Updated dependencies [b290410] + - @trigger.dev/sdk@0.2.11 + - @trigger.dev/providers@0.1.9 + ## 0.1.14 ### Patch Changes diff --git a/packages/trigger-integrations/package.json b/packages/trigger-integrations/package.json index b3807920f08..e323c395721 100644 --- a/packages/trigger-integrations/package.json +++ b/packages/trigger-integrations/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/integrations", - "version": "0.1.14", + "version": "0.1.15", "description": "trigger.dev integrations", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/trigger-providers/CHANGELOG.md b/packages/trigger-providers/CHANGELOG.md index dba9c242ee6..a021c9863b1 100644 --- a/packages/trigger-providers/CHANGELOG.md +++ b/packages/trigger-providers/CHANGELOG.md @@ -1,5 +1,11 @@ # @trigger.dev/providers +## 0.1.9 + +### Patch Changes + +- b290410: Slack blocks support + ## 0.1.8 ### Patch Changes diff --git a/packages/trigger-providers/package.json b/packages/trigger-providers/package.json index df814077f5a..bb8f5d49682 100644 --- a/packages/trigger-providers/package.json +++ b/packages/trigger-providers/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/providers", - "version": "0.1.8", + "version": "0.1.9", "description": "trigger.dev API providers with schemas", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index 1b84f33cfdc..5d75a5f4ede 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sdk +## 0.2.11 + +### Patch Changes + +- 52d21ac: Added support for delaying delivery when sending custom events +- b290410: Slack blocks support + ## 0.2.10 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index ad5074fbc28..2870b334e03 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "0.2.10", + "version": "0.2.11", "description": "trigger.dev Node.JS SDK", "main": "./dist/index.js", "types": "./dist/index.d.ts",