diff --git a/.tool-versions b/.tool-versions index 789d54cc38ba4..d9f6abe88e652 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ -nodejs 20.13.1 +nodejs 18.17.0 pnpm 9.14.2 python 3.11.5 poetry 1.6.1 diff --git a/components/slack_v2_test/README.md b/components/slack_v2_test/README.md new file mode 100644 index 0000000000000..72f31a7d6dcc7 --- /dev/null +++ b/components/slack_v2_test/README.md @@ -0,0 +1,83 @@ +# Overview + +The Pipedream Slack app enables you to build event-driven workflows that interact with the Slack API. Once you authorize the Pipedream app's access to your workspace, you can use [Pipedream workflows](/workflows/) to perform common Slack [actions](#workflow-actions) or [write your own code](/code/) against the Slack API. + +The Pipedream Slack app is not a typical app. You don't interact with it directly as a bot, and it doesn't add custom functionality to your workspace out of the box. It makes it easier to automate anything you'd typically use the Slack API for, using Pipedream workflows. + +- Automate posting updates to your team channels +- Create a bot to answer common questions +- Integrate with your existing tools and services +- And much more! + +# Getting Started + +## Should I use the Slack or Slack Bot app on Pipedream? + +The Slack app is the easiest and most convenient option to get started. It installs the official Pipedream bot into your Slack workspace with just a few clicks. + +However, if you'd like to use your own bot registered with the [Slack API](https://api.slack.com), you can use the [Slack Bot app](https://pipedream.com/apps/slack-bot) instead. + +The Slack Bot requires a bot token to allow your Pipedream workflows to authenticate as your bot. The extra setup steps allow you to list your custom bot on the Slack Marketplace or install the bot on other workspaces as your bot's name instead of as Pipedream. + +## Accounts + +1. Visit [https://pipedream.com/accounts](https://pipedream.com/accounts). +2. Click on the **Click Here To Connect An App** button in the top-right. +3. Search for "Slack" among the list of apps and select it. +4. This will open a new window asking you to allow Pipedream access to your Slack workspace. Choose the right workspace where you'd like to install the app, then click **Allow**. +5. That's it! You can now use this Slack account in any [actions](#workflow-actions) or [link it to any code step](/connected-accounts/#connecting-accounts). + +## Within a workflow + +1. [Create a new workflow](https://pipedream.com/new). +2. Select your trigger (HTTP, Cron, etc.). +3. Click the **+** button below the trigger step and search for "Slack". +4. Select the **Send a Message** action. +5. Click the **Connect Account** button near the top of the step. This will prompt you to select any existing Slack accounts you've previously authenticated with Pipedream, or you can select a **New** account. Clicking **New** opens a new window asking you to allow Pipedream access to your Slack workspace. Choose the right workspace where you'd like to install the app, then click **Allow**. +6. After allowing access, you can connect to the Slack API using any of the Slack actions within a Pipedream workflow. + +# Example Use Cases + +- **Automated Standup Reports**: Trigger a workflow on Pipedream to collect standup updates from team members within a Slack channel at a scheduled time. The workflow compiles updates into a formatted report and posts it to a designated channel or sends it via email using an app like SendGrid. + +- **Customer Support Ticketing**: Use Pipedream to monitor a Slack support channel for new messages. On detecting a message, the workflow creates a ticket in a customer support platform like Zendesk or Jira. It can also format and forward critical information back to the Slack channel to keep the team updated. + +- **Real-time CRM Updates**: Configure a Pipedream workflow to listen for specific trigger words in sales-related Slack channels. When mentioned, the workflow fetches corresponding data from a CRM tool like Salesforce and posts the latest deal status or customer information in the Slack conversation for quick reference. + + +# Troubleshooting + +## Error Responses + +Slack's API will always return JSON, regardless if the request was successfully processed or not. + +Each JSON response includes an `ok` boolean property indicating whether the action succeeded or failed. + +Example of a successful response: + +```json +{ + "ok": true +} +``` + +If the `ok` property is false, Slack will also include an `error` property with a short machine-readable code that describes the error. + +Example of a failure: +```json +{ + "ok": false, + "error": "invalid_parameters" +} +``` + +Additionally, if the action is successful, there's still a chance of a `warning` property in the response. This may contain a comma-separated list of warning codes. + +Example of a successful response, but with warnings: + +```json +{ + "ok": true, + "warnings": "invalid_character_set" +} +``` diff --git a/components/slack_v2_test/actions/add-emoji-reaction/add-emoji-reaction.mjs b/components/slack_v2_test/actions/add-emoji-reaction/add-emoji-reaction.mjs new file mode 100644 index 0000000000000..badbcf784f2d0 --- /dev/null +++ b/components/slack_v2_test/actions/add-emoji-reaction/add-emoji-reaction.mjs @@ -0,0 +1,43 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-add-emoji-reaction", + name: "Add Emoji Reaction", + description: "Add an emoji reaction to a message. [See the documentation](https://api.slack.com/methods/reactions.add)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + description: "Channel where the message to add reaction to was posted.", + }, + timestamp: { + propDefinition: [ + slack, + "messageTs", + ], + description: "Timestamp of the message to add reaction to. e.g. `1403051575.000407`.", + }, + icon_emoji: { + propDefinition: [ + slack, + "icon_emoji", + ], + description: "Provide an emoji to use as the icon for this reaction. E.g. `fire`", + optional: false, + }, + }, + async run({ $ }) { + const response = await this.slack.addReactions({ + channel: this.conversation, + timestamp: this.timestamp, + name: this.icon_emoji, + }); + $.export("$summary", `Successfully added ${this.icon_emoji} emoji reaction.`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/approve-workflow/approve-workflow.mjs b/components/slack_v2_test/actions/approve-workflow/approve-workflow.mjs new file mode 100644 index 0000000000000..1656a74074e89 --- /dev/null +++ b/components/slack_v2_test/actions/approve-workflow/approve-workflow.mjs @@ -0,0 +1,87 @@ +import slack from "../../slack_v2_test.app.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + key: "slack_v2_test-approve-workflow", + name: "Approve Workflow", + description: "Suspend the workflow until approved by a Slack message. [See the documentation](https://pipedream.com/docs/code/nodejs/rerun#flowsuspend)", + version: "0.1.2", + type: "action", + props: { + slack, + channelType: { + type: "string", + label: "Channel Type", + description: "The type of channel to send to. User/Direct Message (im), Group (mpim), Private Channel or Public Channel", + async options() { + return constants.CHANNEL_TYPE_OPTIONS; + }, + }, + conversation: { + propDefinition: [ + slack, + "conversation", + (c) => ({ + types: c.channelType === "Channels" + ? [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ] + : [ + c.channelType, + ], + }), + ], + }, + message: { + type: "string", + label: "Message", + description: "Text to include with the Approve and Cancel Buttons", + }, + }, + async run({ $ }) { + const { + resume_url, cancel_url, + } = $.flow.suspend(); + + const response = await this.slack.postChatMessage({ + text: "Click here to approve or cancel workflow", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: this.message, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Approve", + }, + style: "primary", + url: resume_url, + }, + { + type: "button", + text: { + type: "plain_text", + text: "Cancel", + }, + style: "danger", + url: cancel_url, + }, + ], + }, + ], + channel: this.conversation, + }); + + $.export("$summary", "Successfully sent message"); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/archive-channel/archive-channel.mjs b/components/slack_v2_test/actions/archive-channel/archive-channel.mjs new file mode 100644 index 0000000000000..3123dac397e42 --- /dev/null +++ b/components/slack_v2_test/actions/archive-channel/archive-channel.mjs @@ -0,0 +1,33 @@ +import slack from "../../slack_v2_test.app.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + key: "slack_v2_test-archive-channel", + name: "Archive Channel", + description: "Archive a channel. [See the documentation](https://api.slack.com/methods/conversations.archive)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + constants.CHANNEL_TYPE.MPIM, + ], + }), + ], + }, + }, + async run({ $ }) { + const response = await this.slack.archiveConversations({ + channel: this.conversation, + }); + $.export("$summary", "Successfully archived channel."); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/common/build-blocks.mjs b/components/slack_v2_test/actions/common/build-blocks.mjs new file mode 100644 index 0000000000000..fdbc9a70e4b17 --- /dev/null +++ b/components/slack_v2_test/actions/common/build-blocks.mjs @@ -0,0 +1,182 @@ +import common from "./send-message.mjs"; + +export default { + props: { + passArrayOrConfigure: { + type: "string", + label: "Add Blocks - Reference Existing Blocks Array or Configure Manually?", + description: "Would you like to reference an array of blocks from a previous step (for example, `{{steps.blocks.$return_value}}`), or configure them in this action?", + options: [ + { + label: "Reference an array of blocks", + value: "array", + }, + { + label: "Configure blocks individually (maximum 5 blocks)", + value: "configure", + }, + ], + optional: true, + reloadProps: true, + }, + }, + methods: { + // This adds a visual separator in the props form between each block + separator() { + return ` + + --- + + `; + }, + createBlockProp(type, label, description) { + return { + type, + label, + description: `${description} ${this.separator()}`, + }; + }, + createBlock(type, text) { + if (type === "section") { + return { + type: "section", + text: { + type: "mrkdwn", + text, + }, + }; + } else if (type === "context") { + const elements = Array.isArray(text) + ? text.map((t) => ({ + type: "mrkdwn", + text: t, + })) + : [ + { + type: "mrkdwn", + text, + }, + ]; + return { + type: "context", + elements, + }; + } else if (type === "link_button") { + const buttons = Object.keys(text).map((buttonText) => ({ + type: "button", + text: { + type: "plain_text", + text: buttonText, + emoji: true, + }, + url: text[buttonText], // Access the URL using buttonText as the key + action_id: `actionId-${Math.random().toString(36) + .substr(2, 9)}`, // Generates a random action_id + })); + + return { + type: "actions", + elements: buttons, + }; + } + }, + }, + async additionalProps(existingProps) { + await common.additionalProps.call(this, existingProps); + const props = {}; + const sectionDescription = "Add a **section** block to your message and configure with plain text or mrkdwn. See [Slack's docs](https://api.slack.com/reference/block-kit/blocks?ref=bk#section) for more info."; + const contextDescription = "Add a **context** block to your message and configure with plain text or mrkdwn. Define multiple items if you'd like multiple elements in the context block. See [Slack's docs](https://api.slack.com/reference/block-kit/blocks?ref=bk#context) for more info."; + const linkButtonDescription = "Add a **link button** to your message. Enter the button text as the key and the link URL as the value. Configure multiple buttons in the array to render them inline, or add additional Button Link blocks to render them vertically. See [Slack's docs](https://api.slack.com/reference/block-kit/blocks?ref=bk#actions) for more info."; + const propsSection = this.createBlockProp("string", "Section Block Text", sectionDescription); + const propsContext = this.createBlockProp("string[]", "Context Block Text", contextDescription); + const propsLinkButton = this.createBlockProp("object", "Link Button", linkButtonDescription); + + if (!this.passArrayOrConfigure) { + return props; + } + if (this.passArrayOrConfigure == "array") { + props.blocks = { + type: common.props.slack.propDefinitions.blocks.type, + label: common.props.slack.propDefinitions.blocks.label, + description: common.props.slack.propDefinitions.blocks.description, + }; + } else { + props.blockType = { + type: "string", + label: "Block Type", + description: "Select the type of block to add. Refer to [Slack's docs](https://api.slack.com/reference/block-kit/blocks) for more info.", + options: [ + { + label: "Section", + value: "section", + }, + { + label: "Context", + value: "context", + }, + { + label: "Link Button", + value: "link_button", + }, + ], + reloadProps: true, + };} + let currentBlockType = this.blockType; + for (let i = 1; i <= 5; i++) { + if (currentBlockType === "section") { + props[`section${i}`] = propsSection; + } else if (currentBlockType === "context") { + props[`context${i}`] = propsContext; + } else if (currentBlockType === "link_button") { + props[`linkButton${i}`] = propsLinkButton; + } + + if (i < 5 && currentBlockType) { // Check if currentBlockType is set before adding nextBlockType + props[`nextBlockType${i}`] = { + type: "string", + label: "Next Block Type", + options: [ + { + label: "Section", + value: "section", + }, + { + label: "Context", + value: "context", + }, + { + label: "Link Button", + value: "link_button", + }, + ], + optional: true, + reloadProps: true, + }; + currentBlockType = this[`nextBlockType${i}`]; + } + } + return props; + }, + async run() { + let blocks = []; + if (this.passArrayOrConfigure == "array") { + blocks = this.blocks; + } else { + for (let i = 1; i <= 5; i++) { + if (this[`section${i}`]) { + blocks.push(this.createBlock("section", this[`section${i}`])); + } + + if (this[`context${i}`]) { + blocks.push(this.createBlock("context", this[`context${i}`])); + } + + if (this[`linkButton${i}`]) { + blocks.push(this.createBlock("link_button", this[`linkButton${i}`])); + } + } + } + return blocks; + }, +}; + diff --git a/components/slack_v2_test/actions/common/send-message.mjs b/components/slack_v2_test/actions/common/send-message.mjs new file mode 100644 index 0000000000000..429635576dec5 --- /dev/null +++ b/components/slack_v2_test/actions/common/send-message.mjs @@ -0,0 +1,266 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + props: { + slack, + as_user: { + propDefinition: [ + slack, + "as_user", + ], + }, + addToChannel: { + propDefinition: [ + slack, + "addToChannel", + ], + optional: true, + }, + post_at: { + propDefinition: [ + slack, + "post_at", + ], + }, + include_sent_via_pipedream_flag: { + type: "boolean", + optional: true, + default: true, + label: "Include link to Pipedream", + description: "Defaults to `true`, includes a link to Pipedream at the end of your Slack message.", + }, + customizeBotSettings: { + type: "boolean", + label: "Customize Bot Settings", + description: "Customize the username and/or icon of the Bot", + optional: true, + reloadProps: true, + }, + username: { + propDefinition: [ + slack, + "username", + ], + hidden: true, + }, + icon_emoji: { + propDefinition: [ + slack, + "icon_emoji", + ], + hidden: true, + }, + icon_url: { + propDefinition: [ + slack, + "icon_url", + ], + hidden: true, + }, + replyToThread: { + type: "boolean", + label: "Reply to Thread", + description: "Reply to an existing thread", + optional: true, + reloadProps: true, + }, + thread_ts: { + propDefinition: [ + slack, + "messageTs", + ], + description: "Provide another message's `ts` value to make this message a reply (e.g., if triggering on new Slack messages, enter `{{event.ts}}`). Avoid using a reply's `ts` value; use its parent instead. e.g. `1403051575.000407`.", + optional: true, + hidden: true, + }, + thread_broadcast: { + propDefinition: [ + slack, + "thread_broadcast", + ], + hidden: true, + }, + addMessageMetadata: { + type: "boolean", + label: "Add Message Metadata", + description: "Set the metadata event type and payload", + optional: true, + reloadProps: true, + }, + metadata_event_type: { + propDefinition: [ + slack, + "metadata_event_type", + ], + hidden: true, + }, + metadata_event_payload: { + propDefinition: [ + slack, + "metadata_event_payload", + ], + hidden: true, + }, + configureUnfurlSettings: { + type: "boolean", + label: "Configure Unfurl Settings", + description: "Configure settings for unfurling links and media", + optional: true, + reloadProps: true, + }, + unfurl_links: { + propDefinition: [ + slack, + "unfurl_links", + ], + hidden: true, + }, + unfurl_media: { + propDefinition: [ + slack, + "unfurl_media", + ], + hidden: true, + }, + }, + async additionalProps(props) { + if (this.conversation && this.replyToThread) { + props.thread_ts.hidden = false; + props.thread_broadcast.hidden = false; + } + if (this.customizeBotSettings) { + props.username.hidden = false; + props.icon_emoji.hidden = false; + props.icon_url.hidden = false; + } + if (this.addMessageMetadata) { + props.metadata_event_type.hidden = false; + props.metadata_event_payload.hidden = false; + } + if (this.configureUnfurlSettings) { + props.unfurl_links.hidden = false; + props.unfurl_media.hidden = false; + } + return {}; + }, + methods: { + _makeSentViaPipedreamBlock() { + const workflowId = process.env.PIPEDREAM_WORKFLOW_ID; + const baseLink = "https://pipedream.com"; + const linkText = !workflowId + ? "Pipedream Connect" + : "Pipedream"; + + const link = !workflowId + ? `${baseLink}/connect` + : `${baseLink}/@/${workflowId}?o=a&a=slack`; + + return { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": `Sent via <${link}|${linkText}>`, + }, + ], + }; + }, + _makeTextBlock(mrkdwn = true) { + const { text } = this; + let serializedText = text; + // The Slack SDK expects the value of text's "text" property to be a string. If this.text is + // anything other than string, number, or boolean, then encode it as a JSON string. + if (typeof text !== "string" && typeof text !== "number" && typeof text !== "boolean") { + serializedText = JSON.stringify(text); + } + return { + "type": "section", + "text": { + "type": mrkdwn + ? "mrkdwn" + : "plain_text", + "text": serializedText, + }, + }; + }, + getChannelId() { + return this.conversation ?? this.reply_channel; + }, + }, + async run({ $ }) { + const channelId = await this.getChannelId(); + let channel; + + if (this.addToChannel) { + await this.slack.maybeAddAppToChannels([ + channelId, + ]); + } else if (!this.as_user) { + channel = (await this.slack.checkAccessToChannel(channelId))?.channel; + } + + let blocks = this.blocks; + + if (!blocks) { + blocks = [ + this._makeTextBlock(this.mrkdwn), + ]; + } else if (typeof blocks === "string") { + blocks = JSON.parse(blocks); + } + + if (this.include_sent_via_pipedream_flag) { + const sentViaPipedreamText = this._makeSentViaPipedreamBlock(); + blocks.push(sentViaPipedreamText); + } + + let metadataEventPayload; + + if (this.metadata_event_type) { + + if (typeof this.metadata_event_payload === "string") { + try { + metadataEventPayload = JSON.parse(this.metadata_event_payload); + } catch (error) { + throw new Error(`Invalid JSON in metadata_event_payload: ${error.message}`); + } + } + + this.metadata = { + event_type: this.metadata_event_type, + event_payload: metadataEventPayload, + }; + } + + const obj = { + text: this.text, + channel: channelId, + attachments: this.attachments, + unfurl_links: this.unfurl_links, + unfurl_media: this.unfurl_media, + parse: this.parse, + as_user: this.as_user, + username: this.username, + icon_emoji: this.icon_emoji, + icon_url: this.icon_url, + mrkdwn: this.mrkdwn, + blocks, + link_names: this.link_names, + reply_broadcast: this.thread_broadcast, + thread_ts: this.thread_ts, + metadata: this.metadata || null, + }; + + if (this.post_at) { + obj.post_at = Math.floor(new Date(this.post_at).getTime() / 1000); + return await this.slack.scheduleMessage(obj); + } + const resp = await this.slack.postChatMessage(obj); + channel ??= (await this.slack.conversationsInfo({ + channel: resp.channel, + })).channel; + const channelName = await this.slack.getChannelDisplayName(channel); + $.export("$summary", `Successfully sent a message to ${channelName}`); + return resp; + }, +}; diff --git a/components/slack_v2_test/actions/create-channel/create-channel.mjs b/components/slack_v2_test/actions/create-channel/create-channel.mjs new file mode 100644 index 0000000000000..92982e86e4315 --- /dev/null +++ b/components/slack_v2_test/actions/create-channel/create-channel.mjs @@ -0,0 +1,35 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-create-channel", + name: "Create a Channel", + description: "Create a new channel. [See the documentation](https://api.slack.com/methods/conversations.create)", + version: "0.0.3", + type: "action", + props: { + slack, + channelName: { + label: "Channel name", + description: "Name of the public or private channel to create", + type: "string", + }, + isPrivate: { + label: "Is private?", + type: "boolean", + description: "`false` by default. Pass `true` to create a private channel instead of a public one.", + default: false, + optional: true, + }, + }, + async run({ $ }) { + // parse name + const name = this.channelName.replace(/\s+/g, "-").toLowerCase(); + + const response = await this.slack.createConversations({ + name, + is_private: this.isPrivate, + }); + $.export("$summary", `Successfully created channel ${this.channelName}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/create-reminder/create-reminder.mjs b/components/slack_v2_test/actions/create-reminder/create-reminder.mjs new file mode 100644 index 0000000000000..634049d8b0ac5 --- /dev/null +++ b/components/slack_v2_test/actions/create-reminder/create-reminder.mjs @@ -0,0 +1,47 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-create-reminder", + name: "Create Reminder", + description: "Create a reminder. [See the documentation](https://api.slack.com/methods/reminders.add)", + version: "0.0.3", + type: "action", + props: { + slack, + text: { + propDefinition: [ + slack, + "text", + ], + }, + timestamp: { + type: "string", + label: "Timestamp", + description: "When this reminder should happen: the Unix timestamp (up to five years from now), the number of seconds until the reminder (if within 24 hours), or a natural language description (Ex. in 15 minutes, or every Thursday)", + }, + team_id: { + propDefinition: [ + slack, + "team", + ], + optional: true, + }, + user: { + propDefinition: [ + slack, + "user", + ], + optional: true, + }, + }, + async run({ $ }) { + const response = await this.slack.addReminders({ + text: this.text, + team_id: this.team_id, + time: this.timestamp, + user: this.user, + }); + $.export("$summary", `Successfully created reminder with ID ${response.reminder.id}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/delete-file/delete-file.mjs b/components/slack_v2_test/actions/delete-file/delete-file.mjs new file mode 100644 index 0000000000000..5d76e0a250e72 --- /dev/null +++ b/components/slack_v2_test/actions/delete-file/delete-file.mjs @@ -0,0 +1,34 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-delete-file", + name: "Delete File", + description: "Delete a file. [See the documentation](https://api.slack.com/methods/files.delete)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + file: { + propDefinition: [ + slack, + "file", + (c) => ({ + channel: c.conversation, + }), + ], + }, + }, + async run({ $ }) { + const response = await this.slack.deleteFiles({ + file: this.file, + }); + $.export("$summary", `Successfully deleted file with ID ${this.file}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/delete-message/delete-message.mjs b/components/slack_v2_test/actions/delete-message/delete-message.mjs new file mode 100644 index 0000000000000..2e3bde0a2bd27 --- /dev/null +++ b/components/slack_v2_test/actions/delete-message/delete-message.mjs @@ -0,0 +1,44 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-delete-message", + name: "Delete Message", + description: "Delete a message. [See the documentation](https://api.slack.com/methods/chat.delete)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + timestamp: { + propDefinition: [ + slack, + "messageTs", + ], + }, + as_user: { + propDefinition: [ + slack, + "as_user", + ], + description: "Pass true to update the message as the authed user. Bot users in this context are considered authed users.", + }, + }, + async run({ $ }) { + if (!this.as_user) { + await this.slack.checkAccessToChannel(this.conversation); + } + + const response = await this.slack.deleteMessage({ + channel: this.conversation, + ts: this.timestamp, + as_user: this.as_user, + }); + $.export("$summary", "Successfully deleted message."); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/find-message/find-message.mjs b/components/slack_v2_test/actions/find-message/find-message.mjs new file mode 100644 index 0000000000000..2b9dacf1aba23 --- /dev/null +++ b/components/slack_v2_test/actions/find-message/find-message.mjs @@ -0,0 +1,100 @@ +import { axios } from "@pipedream/platform"; +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-find-message", + name: "Find Message", + description: "Find a Slack message. [See the documentation](https://api.slack.com/methods/search.messages)", + version: "0.1.1", + type: "action", + props: { + slack, + query: { + propDefinition: [ + slack, + "query", + ], + }, + maxResults: { + type: "integer", + label: "Max Results", + description: "The maximum number of messages to return", + default: 20, + optional: true, + }, + sort: { + type: "string", + label: "Sort", + description: "Return matches sorted by either `score` or `timestamp`", + options: [ + "score", + "timestamp", + ], + optional: true, + }, + sortDirection: { + type: "string", + label: "Sort Direction", + description: "Sort ascending (asc) or descending (desc)`", + options: [ + "desc", + "asc", + ], + optional: true, + }, + }, + methods: { + async searchMessages($, params) { + const data = await axios($, { + method: "POST", + url: "https://slack.com/api/assistant.search.context", + data: { + query: params.query, + sort: params.sort, + sort_dir: params.sort_dir, + cursor: params.cursor, + }, + headers: { + "Authorization": `Bearer ${this.slack.getToken()}`, + "Content-Type": "application/json", + }, + }); + if (!data.ok) { + throw new Error(data.error || "An error occurred while searching messages"); + } + return data; + }, + }, + async run({ $ }) { + const matches = []; + const params = { + query: this.query, + sort: this.sort, + sort_dir: this.sortDirection, + }; + let cursor; + + do { + if (cursor) { + params.cursor = cursor; + } + const response = await this.searchMessages($, params); + const messages = response.results?.messages || []; + matches.push(...messages); + if (matches.length >= this.maxResults) { + break; + } + cursor = response.response_metadata?.next_cursor; + } while (cursor); + + if (matches.length > this.maxResults) { + matches.length = this.maxResults; + } + + $.export("$summary", `Found ${matches.length} matching message${matches.length === 1 + ? "" + : "s"}`); + + return matches; + }, +}; diff --git a/components/slack_v2_test/actions/find-user-by-email/find-user-by-email.mjs b/components/slack_v2_test/actions/find-user-by-email/find-user-by-email.mjs new file mode 100644 index 0000000000000..80c53a93ec156 --- /dev/null +++ b/components/slack_v2_test/actions/find-user-by-email/find-user-by-email.mjs @@ -0,0 +1,27 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-find-user-by-email", + name: "Find User by Email", + description: "Find a user by matching against their email. [See the documentation](https://api.slack.com/methods/users.lookupByEmail)", + version: "0.0.3", + type: "action", + props: { + slack, + email: { + propDefinition: [ + slack, + "email", + ], + }, + }, + async run({ $ }) { + const response = await this.slack.lookupUserByEmail({ + email: this.email, + }); + if (response.ok) { + $.export("$summary", `Successfully found user with ID ${response.user.id}`); + } + return response; + }, +}; diff --git a/components/slack_v2_test/actions/get-file/get-file.mjs b/components/slack_v2_test/actions/get-file/get-file.mjs new file mode 100644 index 0000000000000..d0af295a88099 --- /dev/null +++ b/components/slack_v2_test/actions/get-file/get-file.mjs @@ -0,0 +1,56 @@ +import constants from "../../common/constants.mjs"; +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-get-file", + name: "Get File", + description: "Return information about a file. [See the documentation](https://api.slack.com/methods/files.info)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + description: "Select a public or private channel", + }, + addToChannel: { + propDefinition: [ + slack, + "addToChannel", + ], + }, + file: { + propDefinition: [ + slack, + "file", + (c) => ({ + channel: c.conversation, + }), + ], + }, + }, + async run({ $ }) { + if (this.addToChannel) { + await this.slack.maybeAddAppToChannels([ + this.conversation, + ]); + } else { + await this.slack.checkAccessToChannel(this.conversation); + } + + const response = await this.slack.getFileInfo({ + file: this.file, + }); + $.export("$summary", `Successfully retrieved file with ID ${this.file}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/invite-user-to-channel/invite-user-to-channel.mjs b/components/slack_v2_test/actions/invite-user-to-channel/invite-user-to-channel.mjs new file mode 100644 index 0000000000000..02a13402c35c7 --- /dev/null +++ b/components/slack_v2_test/actions/invite-user-to-channel/invite-user-to-channel.mjs @@ -0,0 +1,40 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-invite-user-to-channel", + name: "Invite User to Channel", + description: "Invite a user to an existing channel. [See the documentation](https://api.slack.com/methods/conversations.invite)", + version: "0.0.5", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + user: { + propDefinition: [ + slack, + "user", + ], + }, + }, + async run({ $ }) { + try { + const response = await this.slack.inviteToConversation({ + channel: this.conversation, + users: this.user, + }); + $.export("$summary", `Successfully invited user ${this.user} to channel with ID ${this.conversation}`); + return response; + } catch (error) { + if (error.includes("already_in_channel")) { + $.export("$summary", `The user ${this.user} is already in the channel`); + return; + } + throw error; + } + }, +}; diff --git a/components/slack_v2_test/actions/kick-user/kick-user.mjs b/components/slack_v2_test/actions/kick-user/kick-user.mjs new file mode 100644 index 0000000000000..99f237b99a23f --- /dev/null +++ b/components/slack_v2_test/actions/kick-user/kick-user.mjs @@ -0,0 +1,51 @@ +import slack from "../../slack_v2_test.app.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + key: "slack_v2_test-kick-user", + name: "Kick User", + description: "Remove a user from a conversation. [See the documentation](https://api.slack.com/methods/conversations.kick)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + constants.CHANNEL_TYPE.MPIM, + ], + }), + ], + }, + user: { + propDefinition: [ + slack, + "user", + (c) => ({ + channelId: c.conversation, + }), + ], + }, + }, + async run({ $ }) { + try { + const response = await this.slack.kickUserFromConversation({ + channel: this.conversation, + user: this.user, + }); + $.export("$summary", `Successfully kicked user ${this.user} from channel with ID ${this.conversation}`); + return response; + } catch (error) { + if (error.includes("not_in_channel")) { + $.export("$summary", `The user ${this.user} is not in the channel`); + return; + } + throw error; + } + }, +}; diff --git a/components/slack_v2_test/actions/list-channels/list-channels.mjs b/components/slack_v2_test/actions/list-channels/list-channels.mjs new file mode 100644 index 0000000000000..e6232964765f5 --- /dev/null +++ b/components/slack_v2_test/actions/list-channels/list-channels.mjs @@ -0,0 +1,47 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-channels", + name: "List Channels", + description: "Return a list of all channels in a workspace. [See the documentation](https://api.slack.com/methods/conversations.list)", + version: "0.0.3", + type: "action", + props: { + slack, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + const allChannels = []; + const params = { + limit: this.pageSize, + }; + let page = 0; + + do { + const { + channels, response_metadata: { next_cursor: nextCursor }, + } = await this.slack.conversationsList(params); + allChannels.push(...channels); + params.cursor = nextCursor; + page++; + } while (params.cursor && page < this.numPages); + + $.export("$summary", `Successfully found ${allChannels.length} channel${allChannels.length === 1 + ? "" + : "s"}`); + return { + channels: allChannels, + }; + }, +}; diff --git a/components/slack_v2_test/actions/list-files/list-files.mjs b/components/slack_v2_test/actions/list-files/list-files.mjs new file mode 100644 index 0000000000000..479c793099f6d --- /dev/null +++ b/components/slack_v2_test/actions/list-files/list-files.mjs @@ -0,0 +1,90 @@ +import constants from "../../common/constants.mjs"; +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-files", + name: "List Files", + description: "Return a list of files within a team. [See the documentation](https://api.slack.com/methods/files.list)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + description: "Select a public or private channel", + }, + addToChannel: { + propDefinition: [ + slack, + "addToChannel", + ], + }, + team_id: { + propDefinition: [ + slack, + "team", + ], + optional: true, + }, + user: { + propDefinition: [ + slack, + "user", + ], + optional: true, + }, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + if (this.addToChannel) { + await this.slack.maybeAddAppToChannels([ + this.conversation, + ]); + } else { + await this.slack.checkAccessToChannel(this.conversation); + } + + const allFiles = []; + const params = { + channel: this.conversation, + user: this.user, + team_id: this.team_id, + page: 1, + count: this.pageSize, + }; + let hasMore; + + do { + const { files } = await this.slack.listFiles(params); + allFiles.push(...files); + hasMore = files.length; + params.page++; + } while (hasMore && params.page <= this.numPages); + + $.export("$summary", `Successfully retrieved ${allFiles.length} file${allFiles.length === 1 + ? "" + : "s"}`); + + return allFiles; + }, +}; diff --git a/components/slack_v2_test/actions/list-group-members/list-group-members.mjs b/components/slack_v2_test/actions/list-group-members/list-group-members.mjs new file mode 100644 index 0000000000000..f1b2015c6a5ae --- /dev/null +++ b/components/slack_v2_test/actions/list-group-members/list-group-members.mjs @@ -0,0 +1,63 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-group-members", + name: "List Group Members", + description: "List all users in a User Group. [See the documentation](https://api.slack.com/methods/usergroups.users.list)", + version: "0.0.3", + type: "action", + props: { + slack, + userGroup: { + propDefinition: [ + slack, + "userGroup", + ], + }, + team: { + propDefinition: [ + slack, + "team", + ], + optional: true, + description: "Encoded team id where the user group exists, required if org token is used.", + }, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + const members = []; + const params = { + usergroup: this.userGroup, + team_id: this.team, + limit: this.pageSize, + }; + let page = 0; + + do { + const { + users, response_metadata: { next_cursor: nextCursor }, + } = await this.slack.listGroupMembers(params); + members.push(...users); + params.cursor = nextCursor; + page++; + } while (params.cursor && page < this.numPages); + + if (members?.length) { + $.export("$summary", `Successfully retrieved ${members.length} user${members.length === 1 + ? "" + : "s"}`); + } + return members; + }, +}; diff --git a/components/slack_v2_test/actions/list-members-in-channel/list-members-in-channel.mjs b/components/slack_v2_test/actions/list-members-in-channel/list-members-in-channel.mjs new file mode 100644 index 0000000000000..0f1f8bf6ac4cc --- /dev/null +++ b/components/slack_v2_test/actions/list-members-in-channel/list-members-in-channel.mjs @@ -0,0 +1,66 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-members-in-channel", + name: "List Members in Channel", + description: "Retrieve members of a channel. [See the documentation](https://api.slack.com/methods/conversations.members)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + returnUsernames: { + type: "boolean", + label: "Return Usernames", + description: "Optionally, return usernames in addition to IDs", + optional: true, + }, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + let channelMembers = []; + const params = { + channel: this.conversation, + limit: this.pageSize, + }; + let page = 0; + + do { + const { + members, response_metadata: { next_cursor: nextCursor }, + } = await this.slack.listChannelMembers(params); + channelMembers.push(...members); + params.cursor = nextCursor; + page++; + } while (params.cursor && page < this.numPages); + + if (this.returnUsernames) { + const usernames = await this.slack.userNameLookup(channelMembers); + channelMembers = channelMembers?.map((id) => ({ + id, + username: usernames[id], + })) || []; + } + + $.export("$summary", `Successfully retrieved ${channelMembers.length} member${channelMembers.length === 1 + ? "" + : "s"}`); + return channelMembers; + }, +}; diff --git a/components/slack_v2_test/actions/list-replies/list-replies.mjs b/components/slack_v2_test/actions/list-replies/list-replies.mjs new file mode 100644 index 0000000000000..f6edee18dc72d --- /dev/null +++ b/components/slack_v2_test/actions/list-replies/list-replies.mjs @@ -0,0 +1,69 @@ +import constants from "../../common/constants.mjs"; +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-replies", + name: "List Replies", + description: "Retrieve a thread of messages posted to a conversation. [See the documentation](https://api.slack.com/methods/conversations.replies)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + description: "Select a public or private channel", + }, + timestamp: { + propDefinition: [ + slack, + "messageTs", + ], + }, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + const replies = []; + const params = { + channel: this.conversation, + ts: this.timestamp, + limit: this.pageSize, + }; + let page = 0; + + do { + const { + messages, response_metadata: { next_cursor: nextCursor }, + } = await this.slack.getConversationReplies(params); + replies.push(...messages); + params.cursor = nextCursor; + page++; + } while (params.cursor && page < this.numPages); + + $.export("$summary", `Successfully retrieved ${replies.length} reply message${replies.length === 1 + ? "" + : "s"}`); + return { + messages: replies, + }; + }, +}; diff --git a/components/slack_v2_test/actions/list-users/list-users.mjs b/components/slack_v2_test/actions/list-users/list-users.mjs new file mode 100644 index 0000000000000..3dbb863c92712 --- /dev/null +++ b/components/slack_v2_test/actions/list-users/list-users.mjs @@ -0,0 +1,55 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-list-users", + name: "List Users", + description: "Return a list of all users in a workspace. [See the documentation](https://api.slack.com/methods/users.list)", + version: "0.0.3", + type: "action", + props: { + slack, + teamId: { + propDefinition: [ + slack, + "team", + ], + optional: true, + }, + pageSize: { + propDefinition: [ + slack, + "pageSize", + ], + }, + numPages: { + propDefinition: [ + slack, + "numPages", + ], + }, + }, + async run({ $ }) { + const users = []; + const params = { + team_id: this.teamId, + limit: this.pageSize, + }; + let page = 0; + + do { + const { + members, response_metadata: { next_cursor: nextCursor }, + } = await this.slack.usersList(params); + users.push(...members); + params.cursor = nextCursor; + page++; + } while (params.cursor && page < this.numPages); + + $.export("$summary", `Successfully retrieved ${users.length} user${users.length === 1 + ? "" + : "s"}`); + return { + members: users, + }; + }, +}; diff --git a/components/slack_v2_test/actions/reply-to-a-message/reply-to-a-message.mjs b/components/slack_v2_test/actions/reply-to-a-message/reply-to-a-message.mjs new file mode 100644 index 0000000000000..f762be598571b --- /dev/null +++ b/components/slack_v2_test/actions/reply-to-a-message/reply-to-a-message.mjs @@ -0,0 +1,49 @@ +import slack from "../../slack_v2_test.app.mjs"; +import common from "../common/send-message.mjs"; + +export default { + ...common, + key: "slack_v2_test-reply-to-a-message", + name: "Reply to a Message Thread", + description: "Send a message as a threaded reply. See [postMessage](https://api.slack.com/methods/chat.postMessage) or [scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage) docs here", + version: "0.1.3", + type: "action", + props: { + slack: common.props.slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + text: { + propDefinition: [ + slack, + "text", + ], + }, + mrkdwn: { + propDefinition: [ + slack, + "mrkdwn", + ], + }, + ...common.props, + replyToThread: { + ...common.props.replyToThread, + hidden: true, + }, + thread_ts: { + propDefinition: [ + slack, + "messageTs", + ], + }, + thread_broadcast: { + propDefinition: [ + slack, + "thread_broadcast", + ], + }, + }, +}; diff --git a/components/slack_v2_test/actions/send-block-kit-message/send-block-kit-message.mjs b/components/slack_v2_test/actions/send-block-kit-message/send-block-kit-message.mjs new file mode 100644 index 0000000000000..fb8a25580b130 --- /dev/null +++ b/components/slack_v2_test/actions/send-block-kit-message/send-block-kit-message.mjs @@ -0,0 +1,43 @@ +import buildBlocks from "../common/build-blocks.mjs"; +import common from "../common/send-message.mjs"; + +export default { + ...common, + ...buildBlocks, + key: "slack_v2_test-send-block-kit-message", + name: "Build and Send a Block Kit Message", + description: "Configure custom blocks and send to a channel, group, or user. [See the documentation](https://api.slack.com/tools/block-kit-builder).", + version: "0.5.3", + type: "action", + props: { + slack: common.props.slack, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + ], + }, + text: { + type: "string", + label: "Notification Text", + description: "Optionally provide a string for Slack to display as the new message notification (if you do not provide this, notification will be blank).", + optional: true, + }, + ...common.props, + ...buildBlocks.props, + }, + methods: { + ...common.methods, + ...buildBlocks.methods, + async getGeneratedBlocks() { + return await buildBlocks.run.call(this); // call buildBlocks.run with the current context + }, + }, + async run({ $ }) { + this.blocks = await this.getGeneratedBlocks(); // set the blocks prop for common.run to use + const resp = await common.run.call(this, { + $, + }); // call common.run with the current context + return resp; + }, +}; diff --git a/components/slack_v2_test/actions/send-large-message/send-large-message.mjs b/components/slack_v2_test/actions/send-large-message/send-large-message.mjs new file mode 100644 index 0000000000000..733b6b3c653bf --- /dev/null +++ b/components/slack_v2_test/actions/send-large-message/send-large-message.mjs @@ -0,0 +1,94 @@ +import common from "../common/send-message.mjs"; + +export default { + ...common, + key: "slack_v2_test-send-large-message", + name: "Send a Large Message (3000+ characters)", + description: "Send a large message (more than 3000 characters) to a channel, group or user. See [postMessage](https://api.slack.com/methods/chat.postMessage) or [scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage) docs here", + version: "0.1.3", + type: "action", + props: { + slack: common.props.slack, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + ], + }, + text: { + propDefinition: [ + common.props.slack, + "text", + ], + }, + mrkdwn: { + propDefinition: [ + common.props.slack, + "mrkdwn", + ], + }, + ...common.props, + }, + async run({ $ }) { + let channel; + + if (this.addToChannel) { + await this.slack.maybeAddAppToChannels([ + this.conversation, + ]); + } else if (!this.as_user) { + channel = (await this.slack.checkAccessToChannel(this.conversation))?.channel; + } + + if (this.include_sent_via_pipedream_flag) { + const sentViaPipedreamText = this._makeSentViaPipedreamBlock(); + this.text += `\n\n\n${sentViaPipedreamText.elements[0].text}`; + } + + let metadataEventPayload; + + if (this.metadata_event_type) { + if (typeof this.metadata_event_payload === "string") { + try { + metadataEventPayload = JSON.parse(this.metadata_event_payload); + } catch (error) { + throw new Error(`Invalid JSON in metadata_event_payload: ${error.message}`); + } + } + + this.metadata = { + event_type: this.metadata_event_type, + event_payload: metadataEventPayload, + }; + } + + const obj = { + text: this.text, + channel: this.conversation, + as_user: this.as_user, + username: this.username, + icon_emoji: this.icon_emoji, + icon_url: this.icon_url, + mrkdwn: this.mrkdwn, + metadata: this.metadata || null, + reply_broadcast: this.thread_broadcast, + thread_ts: this.thread_ts, + unfurl_links: this.unfurl_links, + unfurl_media: this.unfurl_media, + }; + + let response; + if (this.post_at) { + obj.post_at = this.post_at; + response = await this.slack.scheduleMessage(obj); + } else { + response = await this.slack.postChatMessage(obj); + } + channel ??= (await this.slack.conversationsInfo({ + channel: response.channel, + })).channel; + const channelName = await this.slack.getChannelDisplayName(channel); + $.export("$summary", `Successfully sent a message to ${channelName}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/send-message-advanced/send-message-advanced.mjs b/components/slack_v2_test/actions/send-message-advanced/send-message-advanced.mjs new file mode 100644 index 0000000000000..816fb4d624550 --- /dev/null +++ b/components/slack_v2_test/actions/send-message-advanced/send-message-advanced.mjs @@ -0,0 +1,70 @@ +import common from "../common/send-message.mjs"; +import buildBlocks from "../common/build-blocks.mjs"; + +export default { + ...common, + ...buildBlocks, + key: "slack_v2_test-send-message-advanced", + name: "Send Message (Advanced)", + description: "Customize advanced setttings and send a message to a channel, group or user. See [postMessage](https://api.slack.com/methods/chat.postMessage) or [scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage) docs here", + version: "0.1.4", + type: "action", + props: { + slack: common.props.slack, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + ], + }, + text: { + propDefinition: [ + common.props.slack, + "text", + ], + description: "If you're using `blocks`, this is used as a fallback string to display in notifications. If you aren't, this is the main body text of the message. It can be formatted as plain text, or with mrkdwn.", + }, + mrkdwn: { + propDefinition: [ + common.props.slack, + "mrkdwn", + ], + }, + attachments: { + propDefinition: [ + common.props.slack, + "attachments", + ], + }, + parse: { + propDefinition: [ + common.props.slack, + "parse", + ], + }, + link_names: { + propDefinition: [ + common.props.slack, + "link_names", + ], + }, + ...common.props, + ...buildBlocks.props, + }, + methods: { + ...common.methods, + ...buildBlocks.methods, + async getGeneratedBlocks() { + return await buildBlocks.run.call(this); // call buildBlocks.run with the current context + }, + }, + async run({ $ }) { + if (this.passArrayOrConfigure) { + this.blocks = await this.getGeneratedBlocks(); // set the blocks prop for common.run to use + } + const resp = await common.run.call(this, { + $, + }); // call common.run with the current context + return resp; + }, +}; diff --git a/components/slack_v2_test/actions/send-message-to-channel/send-message-to-channel.mjs b/components/slack_v2_test/actions/send-message-to-channel/send-message-to-channel.mjs new file mode 100644 index 0000000000000..4d3f52cdb667a --- /dev/null +++ b/components/slack_v2_test/actions/send-message-to-channel/send-message-to-channel.mjs @@ -0,0 +1,40 @@ +import common from "../common/send-message.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + ...common, + key: "slack_v2_test-send-message-to-channel", + name: "Send Message to Channel", + description: "Send a message to a public or private channel. [See the documentation](https://api.slack.com/methods/chat.postMessage)", + version: "0.1.4", + type: "action", + props: { + slack: common.props.slack, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + description: "Select a public or private channel", + }, + text: { + propDefinition: [ + common.props.slack, + "text", + ], + }, + mrkdwn: { + propDefinition: [ + common.props.slack, + "mrkdwn", + ], + }, + ...common.props, + }, +}; diff --git a/components/slack_v2_test/actions/send-message-to-user-or-group/send-message-to-user-or-group.mjs b/components/slack_v2_test/actions/send-message-to-user-or-group/send-message-to-user-or-group.mjs new file mode 100644 index 0000000000000..3d1006814f0b6 --- /dev/null +++ b/components/slack_v2_test/actions/send-message-to-user-or-group/send-message-to-user-or-group.mjs @@ -0,0 +1,80 @@ +import common from "../common/send-message.mjs"; +import constants from "../../common/constants.mjs"; +import { ConfigurationError } from "@pipedream/platform"; + +export default { + ...common, + key: "slack_v2_test-send-message-to-user-or-group", + name: "Send Message to User or Group", + description: "Send a message to a user or group. [See the documentation](https://api.slack.com/methods/chat.postMessage)", + version: "0.1.5", + type: "action", + props: { + slack: common.props.slack, + users: { + propDefinition: [ + common.props.slack, + "user", + ], + type: "string[]", + label: "Users", + description: "Select the user(s) to message", + optional: true, + }, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + () => ({ + types: [ + constants.CHANNEL_TYPE.MPIM, + ], + }), + ], + description: "Select the group to message", + optional: true, + }, + text: { + propDefinition: [ + common.props.slack, + "text", + ], + }, + mrkdwn: { + propDefinition: [ + common.props.slack, + "mrkdwn", + ], + }, + ...common.props, + // eslint-disable-next-line pipedream/props-label, pipedream/props-description + addToChannel: { + type: "boolean", + ...common.props.addToChannel, + disabled: true, + hidden: true, + }, + }, + methods: { + ...common.methods, + openConversation(args = {}) { + return this.slack.makeRequest({ + method: "conversations.open", + ...args, + }); + }, + async getChannelId() { + if (!this.conversation && !this.users?.length) { + throw new ConfigurationError("Must select a group or user(s) to message"); + } + + if (this.conversation) { + return this.conversation; + } + const { channel: { id } } = await this.openConversation({ + users: this.users.join(), + }); + return id; + }, + }, +}; diff --git a/components/slack_v2_test/actions/send-message/send-message.mjs b/components/slack_v2_test/actions/send-message/send-message.mjs new file mode 100644 index 0000000000000..cf471daa9a802 --- /dev/null +++ b/components/slack_v2_test/actions/send-message/send-message.mjs @@ -0,0 +1,51 @@ +import common from "../common/send-message.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + ...common, + key: "slack_v2_test-send-message", + name: "Send Message", + description: "Send a message to a user, group, private channel or public channel. [See the documentation](https://api.slack.com/methods/chat.postMessage)", + version: "0.1.4", + type: "action", + props: { + slack: common.props.slack, + channelType: { + type: "string", + label: "Channel Type", + description: "The type of channel to send to. User/Direct Message (im), Group (mpim), Private Channel or Public Channel", + async options() { + return constants.CHANNEL_TYPE_OPTIONS; + }, + }, + conversation: { + propDefinition: [ + common.props.slack, + "conversation", + (c) => ({ + types: c.channelType === "Channels" + ? [ + constants.CHANNEL_TYPE.PUBLIC, + constants.CHANNEL_TYPE.PRIVATE, + ] + : [ + c.channelType, + ], + }), + ], + }, + text: { + propDefinition: [ + common.props.slack, + "text", + ], + }, + mrkdwn: { + propDefinition: [ + common.props.slack, + "mrkdwn", + ], + }, + ...common.props, + }, +}; diff --git a/components/slack_v2_test/actions/set-channel-description/set-channel-description.mjs b/components/slack_v2_test/actions/set-channel-description/set-channel-description.mjs new file mode 100644 index 0000000000000..b675db4a6e2c6 --- /dev/null +++ b/components/slack_v2_test/actions/set-channel-description/set-channel-description.mjs @@ -0,0 +1,32 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-set-channel-description", + name: "Set Channel Description", + description: "Change the description or purpose of a channel. [See the documentation](https://api.slack.com/methods/conversations.setPurpose)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + purpose: { + propDefinition: [ + slack, + "purpose", + ], + }, + }, + async run({ $ }) { + const response = await this.slack.setChannelDescription({ + channel: this.conversation, + purpose: this.purpose, + }); + $.export("$summary", `Successfully set description for channel with ID ${this.conversation}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/set-channel-topic/set-channel-topic.mjs b/components/slack_v2_test/actions/set-channel-topic/set-channel-topic.mjs new file mode 100644 index 0000000000000..c95f48d184693 --- /dev/null +++ b/components/slack_v2_test/actions/set-channel-topic/set-channel-topic.mjs @@ -0,0 +1,32 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-set-channel-topic", + name: "Set Channel Topic", + description: "Set the topic on a selected channel. [See the documentation](https://api.slack.com/methods/conversations.setTopic)", + version: "0.0.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + topic: { + propDefinition: [ + slack, + "topic", + ], + }, + }, + async run({ $ }) { + const response = await this.slack.setChannelTopic({ + channel: this.conversation, + topic: this.topic, + }); + $.export("$summary", `Successfully set topic for channel with ID ${this.conversation}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/set-status/set-status.mjs b/components/slack_v2_test/actions/set-status/set-status.mjs new file mode 100644 index 0000000000000..a7563d79eeef1 --- /dev/null +++ b/components/slack_v2_test/actions/set-status/set-status.mjs @@ -0,0 +1,44 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-set-status", + name: "Set Status", + description: "Set the current status for a user. [See the documentation](https://api.slack.com/methods/users.profile.set)", + version: "0.0.3", + type: "action", + props: { + slack, + statusText: { + type: "string", + label: "Status Text", + description: "The displayed text", + }, + statusEmoji: { + propDefinition: [ + slack, + "icon_emoji", + ], + label: "Status Emoji", + description: "The emoji to display with the status", + optional: true, + }, + statusExpiration: { + type: "string", + label: "Status Expiration", + description: "The datetime of when the status will expire in ISO 8601 format. (Example: `2014-01-01T00:00:00Z`)", + optional: true, + }, + }, + async run({ $ }) { + const response = await this.slack.updateProfile({ + profile: { + status_text: this.statusText, + status_emoji: this.statusEmoji && `:${this.statusEmoji}:`, + status_expiration: this.statusExpiration + && Math.floor(new Date(this.statusExpiration).getTime() / 1000), + }, + }); + $.export("$summary", "Successfully updated status."); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/update-group-members/update-group-members.mjs b/components/slack_v2_test/actions/update-group-members/update-group-members.mjs new file mode 100644 index 0000000000000..bd327e509d2f3 --- /dev/null +++ b/components/slack_v2_test/actions/update-group-members/update-group-members.mjs @@ -0,0 +1,67 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-update-group-members", + name: "Update Groups Members", + description: "Update the list of users for a User Group. [See the documentation](https://api.slack.com/methods/usergroups.users.update)", + version: "0.0.4", + type: "action", + props: { + slack, + userGroup: { + propDefinition: [ + slack, + "userGroup", + ], + }, + usersToAdd: { + propDefinition: [ + slack, + "user", + ], + type: "string[]", + label: "Users to Add", + description: "A list of encoded user IDs that represent the users to add to the group.", + optional: true, + }, + usersToRemove: { + propDefinition: [ + slack, + "user", + ], + type: "string[]", + label: "Users to Remove", + description: "A list of encoded user IDs that represent the users to remove from the group.", + optional: true, + }, + team: { + propDefinition: [ + slack, + "team", + ], + optional: true, + description: "Encoded team id where the user group exists, required if org token is used.", + }, + }, + async run({ $ }) { + const { + userGroup, + usersToAdd = [], + usersToRemove = [], + team, + } = this; + let { users } = await this.slack.listGroupMembers({ + usergroup: userGroup, + team_id: team, + }); + users = users.filter((user) => !usersToRemove.includes(user)); + users.push(...usersToAdd); + const response = await this.slack.updateGroupMembers({ + usergroup: userGroup, + users, + team_id: team, + }); + $.export("$summary", `Successfully updated members of group with ID ${this.userGroup}`); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/update-message/update-message.mjs b/components/slack_v2_test/actions/update-message/update-message.mjs new file mode 100644 index 0000000000000..2ab202380b3d5 --- /dev/null +++ b/components/slack_v2_test/actions/update-message/update-message.mjs @@ -0,0 +1,58 @@ +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-update-message", + name: "Update Message", + description: "Update a message. [See the documentation](https://api.slack.com/methods/chat.update)", + version: "0.1.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + timestamp: { + propDefinition: [ + slack, + "messageTs", + ], + }, + text: { + propDefinition: [ + slack, + "text", + ], + }, + as_user: { + propDefinition: [ + slack, + "as_user", + ], + description: "Pass true to update the message as the authed user. Bot users in this context are considered authed users.", + }, + attachments: { + propDefinition: [ + slack, + "attachments", + ], + }, + }, + async run({ $ }) { + if (!this.as_user) { + await this.slack.checkAccessToChannel(this.conversation); + } + + const response = await this.slack.updateMessage({ + ts: this.timestamp, + text: this.text, + channel: this.conversation, + as_user: this.as_user, + attachments: this.attachments, + }); + $.export("$summary", "Successfully updated message"); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/update-profile/update-profile.mjs b/components/slack_v2_test/actions/update-profile/update-profile.mjs new file mode 100644 index 0000000000000..648d5a3b8dc4e --- /dev/null +++ b/components/slack_v2_test/actions/update-profile/update-profile.mjs @@ -0,0 +1,87 @@ +import slack from "../../slack_v2_test.app.mjs"; +import { ConfigurationError } from "@pipedream/platform"; + +export default { + key: "slack_v2_test-update-profile", + name: "Update Profile", + description: "Update basic profile field such as name or title. [See the documentation](https://api.slack.com/methods/users.profile.set)", + version: "0.0.3", + type: "action", + props: { + slack, + displayName: { + type: "string", + label: "Display Name", + description: "The display name the user has chosen to identify themselves by in their workspace profile", + optional: true, + }, + firstName: { + type: "string", + label: "First Name", + description: "The user's first name", + optional: true, + }, + lastName: { + type: "string", + label: "Last Name", + description: "The user's last name", + optional: true, + }, + phone: { + type: "string", + label: "Phone", + description: "The user's phone number, in any format", + optional: true, + }, + pronouns: { + type: "string", + label: "Pronouns", + description: "The user's pronouns", + optional: true, + }, + title: { + type: "string", + label: "Title", + description: "The user's title", + optional: true, + }, + email: { + type: "string", + label: "Email", + description: "The user's email address. You cannot update your own email using this method. This field can only be changed by admins for users on paid teams.", + optional: true, + }, + user: { + propDefinition: [ + slack, + "user", + ], + description: "ID of user to change. This argument may only be specified by admins on paid teams.", + optional: true, + }, + }, + async run({ $ }) { + if (!this.displayName + && !this.firstName + && !this.lastName + && !this.phone + && !this.pronouns + && !this.title + ) { + throw new ConfigurationError("Please provide at least one value to update"); + } + const response = await this.slack.updateProfile({ + profile: { + display_name: this.displayName, + first_name: this.firstName, + last_name: this.lastName, + phone: this.phone, + pronouns: this.pronouns, + title: this.title, + }, + user: this.user, + }); + $.export("$summary", "Successfully updated profile"); + return response; + }, +}; diff --git a/components/slack_v2_test/actions/upload-file/upload-file.mjs b/components/slack_v2_test/actions/upload-file/upload-file.mjs new file mode 100644 index 0000000000000..a37bde6960120 --- /dev/null +++ b/components/slack_v2_test/actions/upload-file/upload-file.mjs @@ -0,0 +1,100 @@ +import { + ConfigurationError, axios, getFileStreamAndMetadata, +} from "@pipedream/platform"; +import FormData from "form-data"; +import slack from "../../slack_v2_test.app.mjs"; + +export default { + key: "slack_v2_test-upload-file", + name: "Upload File", + description: "Upload a file. [See the documentation](https://api.slack.com/messaging/files#uploading_files)", + version: "0.1.3", + type: "action", + props: { + slack, + conversation: { + propDefinition: [ + slack, + "conversation", + ], + }, + content: { + propDefinition: [ + slack, + "content", + ], + }, + initialComment: { + description: "Will be added as an initial comment before the image", + propDefinition: [ + slack, + "initial_comment", + ], + optional: true, + }, + syncDir: { + type: "dir", + accessMode: "read", + sync: true, + optional: true, + }, + }, + async run({ $ }) { + const { + stream, metadata, + } = await getFileStreamAndMetadata(this.content); + + const filename = this.content.split("/").pop(); + + // Get an upload URL from Slack + const getUploadUrlResponse = await this.slack.getUploadUrl({ + filename, + length: metadata.size, + }); + + if (!getUploadUrlResponse.ok) { + throw new ConfigurationError(`Error getting upload URL: ${JSON.stringify(getUploadUrlResponse)}`); + } + + const { + upload_url: uploadUrl, file_id: fileId, + } = getUploadUrlResponse; + + // Upload the file to the provided URL + const formData = new FormData(); + formData.append("file", stream, { + contentType: metadata.contentType, + knownLength: metadata.size, + filename: metadata.name, + }); + formData.append("filename", filename); + + await axios($, { + url: uploadUrl, + data: formData, + method: "POST", + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${this.slack.getToken()}`, + }, + }); + + // Complete the file upload process in Slack + const completeUploadResponse = await this.slack.completeUpload({ + channel_id: this.conversation, + initial_comment: this.initialComment, + files: [ + { + id: fileId, + }, + ], + }); + + if (!completeUploadResponse.ok) { + throw new Error(`Error completing upload: ${JSON.stringify(completeUploadResponse)}`); + } + + $.export("$summary", "Successfully uploaded file"); + return completeUploadResponse; + }, +}; diff --git a/components/slack_v2_test/common/constants.mjs b/components/slack_v2_test/common/constants.mjs new file mode 100644 index 0000000000000..7d64f48d573d7 --- /dev/null +++ b/components/slack_v2_test/common/constants.mjs @@ -0,0 +1,31 @@ +const MAX_RESOURCES = 800; +const LIMIT = 250; + +const CHANNEL_TYPE = { + PUBLIC: "public_channel", + PRIVATE: "private_channel", + MPIM: "mpim", + IM: "im", +}; + +const CHANNEL_TYPE_OPTIONS = [ + { + label: "Channels", + value: "Channels", + }, + { + label: "Group", + value: CHANNEL_TYPE.MPIM, + }, + { + label: "User / Direct Message", + value: CHANNEL_TYPE.IM, + }, +]; + +export default { + MAX_RESOURCES, + LIMIT, + CHANNEL_TYPE, + CHANNEL_TYPE_OPTIONS, +}; diff --git a/components/slack_v2_test/package.json b/components/slack_v2_test/package.json new file mode 100644 index 0000000000000..aa112b7289df7 --- /dev/null +++ b/components/slack_v2_test/package.json @@ -0,0 +1,22 @@ +{ + "name": "@pipedream/slack_v2_test", + "version": "0.10.2", + "description": "Pipedream Slack v2 Test Components", + "main": "slack_v2_test.app.mjs", + "keywords": [ + "pipedream", + "slack_v2_test" + ], + "homepage": "https://pipedream.com/apps/slack_v2_test", + "author": "Pipedream (https://pipedream.com/)", + "gitHead": "e12480b94cc03bed4808ebc6b13e7fdb3a1ba535", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.1.0", + "@slack/web-api": "^7.9.0", + "async-retry": "^1.3.3", + "lodash": "^4.17.21" + } +} diff --git a/components/slack_v2_test/slack_v2_test.app.mjs b/components/slack_v2_test/slack_v2_test.app.mjs new file mode 100644 index 0000000000000..a0cfeb3e1945d --- /dev/null +++ b/components/slack_v2_test/slack_v2_test.app.mjs @@ -0,0 +1,1102 @@ +import { WebClient } from "@slack/web-api"; +import constants from "./common/constants.mjs"; +import get from "lodash/get.js"; +import retry from "async-retry"; +import { ConfigurationError } from "@pipedream/platform"; + +export default { + type: "app", + app: "slack_v2_test", + propDefinitions: { + user: { + type: "string", + label: "User", + description: "Select a user", + async options({ + prevContext, channelId, + }) { + const types = [ + "im", + ]; + let conversationsResp + = await this.availableConversations(types.join(), prevContext.cursor, true); + if (channelId) { + const { members } = await this.listChannelMembers({ + channel: channelId, + throwRateLimitError: true, + }); + conversationsResp.conversations = conversationsResp.conversations + .filter((c) => members.includes(c.user || c.id)); + } + const userIds = conversationsResp.conversations.map(({ user }) => user).filter(Boolean); + const realNames = await this.realNameLookup(userIds); + return { + options: conversationsResp.conversations.filter((c) => c.user).map((c) => ({ + label: `${realNames[c.user]}`, + value: c.user || c.id, + })), + context: { + cursor: conversationsResp.cursor, + }, + }; + }, + }, + group: { + type: "string", + label: "Group", + description: "Select a group", + async options({ prevContext }) { + let { cursor } = prevContext; + const types = [ + "mpim", + ]; + const resp = await this.availableConversations(types.join(), cursor, true); + return { + options: resp.conversations.map((c) => { + return { + label: c.purpose.value, + value: c.id, + }; + }), + context: { + cursor: resp.cursor, + }, + }; + }, + }, + userGroup: { + type: "string", + label: "User Group", + description: "The encoded ID of the User Group.", + async options() { + const { usergroups } = await this.usergroupsList({ + throwRateLimitError: true, + }); + return usergroups.map((c) => ({ + label: c.name, + value: c.id, + })); + }, + }, + reminder: { + type: "string", + label: "Reminder", + description: "Select a reminder", + async options() { + const { reminders } = await this.remindersList({ + throwRateLimitError: true, + }); + return reminders.map((c) => ({ + label: c.text, + value: c.id, + })); + }, + }, + conversation: { + type: "string", + label: "Channel", + description: "Select a public or private channel, or a user or group", + async options({ + prevContext, types, + }) { + let { cursor } = prevContext; + if (prevContext?.types) { + types = prevContext.types; + } + if (types == null) { + const { response_metadata: { scopes } } = await this.authTest({ + throwRateLimitError: true, + }); + types = [ + "public_channel", + ]; + if (scopes.includes("groups:read")) { + types.push("private_channel"); + } + if (scopes.includes("mpim:read")) { + types.push("mpim"); + } + if (scopes.includes("im:read")) { + types.push("im"); + } + } + const conversationsResp = await this.availableConversations(types.join(), cursor, true); + let conversations, userIds, userNames, realNames; + if (types.includes("im")) { + conversations = conversationsResp.conversations; + userIds = conversations.map(({ user }) => user).filter(Boolean); + } else { + conversations = conversationsResp.conversations.filter((c) => !c.is_im); + } + if (types.includes("mpim")) { + userNames = [ + ...new Set(conversations.filter((c) => c.is_mpim).map((c) => c.purpose.value) + .map((v) => v.match(/@[\w.-]+/g) || []) + .flat() + .map((u) => u.slice(1))), + ]; + } + if ((userIds?.length > 0) || (userNames?.length > 0)) { + // Look up real names for userIds and userNames at the same time to + // minimize number of API calls. + realNames = await this.realNameLookup(userIds, userNames); + } + + return { + options: conversations.map((c) => { + if (c.is_im) { + return { + label: `Direct messaging with: ${realNames[c.user]}`, + value: c.id, + }; + } else if (c.is_mpim) { + const usernames = c.purpose.value.match(/@[\w.-]+/g) || []; + const realnames = usernames.map((u) => realNames[u.slice(1)] || u); + return { + label: realnames.length + ? `Group messaging with: ${realnames.join(", ")}` + : c.purpose.value, + value: c.id, + }; + } else { + return { + label: `${c.is_private + ? "Private" + : "Public"} channel: ${c.name}`, + value: c.id, + }; + } + }), + context: { + types, + cursor: conversationsResp.cursor, + }, + }; + }, + }, + channelId: { + type: "string", + label: "Channel ID", + description: "Select the channel's id.", + async options({ + prevContext, + types = Object.values(constants.CHANNEL_TYPE), + channelsFilter = (channel) => channel, + excludeArchived = true, + }) { + const { + channels, + response_metadata: { next_cursor: cursor }, + } = await this.conversationsList({ + types: types.join(), + cursor: prevContext.cursor, + limit: constants.LIMIT, + exclude_archived: excludeArchived, + throwRateLimitError: true, + }); + + let userNames; + if (types.includes("im")) { + const userIds = channels.filter(({ is_im }) => is_im).map(({ user }) => user); + userNames = await this.userNameLookup(userIds); + } + + const options = channels + .filter(channelsFilter) + .map((c) => { + if (c.is_im) { + return { + label: `Direct messaging with: @${userNames[c.user]}`, + value: c.id, + }; + } else if (c.is_mpim) { + return { + label: c.purpose.value, + value: c.id, + }; + } else { + return { + label: `${c.is_private + ? "Private" + : "Public"} channel: ${c.name}`, + value: c.id, + }; + } + }); + + return { + options, + context: { + cursor, + }, + }; + }, + }, + team: { + type: "string", + label: "Team", + description: "Select a team.", + async options({ prevContext }) { + const { + teams, + response_metadata: { next_cursor: cursor }, + } = await this.authTeamsList({ + cursor: prevContext.cursor, + limit: constants.LIMIT, + throwRateLimitError: true, + }); + + return { + options: teams.map((team) => ({ + label: team.name, + value: team.id, + })), + + context: { + cursor, + }, + }; + }, + }, + messageTs: { + type: "string", + label: "Message Timestamp", + description: "Timestamp of a message. e.g. `1403051575.000407`.", + }, + text: { + type: "string", + label: "Text", + description: "Text of the message to send (see Slack's [formatting docs](https://api.slack.com/reference/surfaces/formatting)). This field is usually necessary, unless you're providing only attachments instead.", + }, + topic: { + type: "string", + label: "Topic", + description: "Text of the new channel topic.", + }, + purpose: { + type: "string", + label: "Purpose", + description: "Text of the new channel purpose.", + }, + query: { + type: "string", + label: "Query", + description: "Search query.", + }, + file: { + type: "string", + label: "File ID", + description: "Specify a file by providing its ID.", + async options({ + channel, page, + }) { + const { files } = await this.listFiles({ + channel, + page: page + 1, + count: constants.LIMIT, + throwRateLimitError: true, + as_bot: true, + }); + return files?.map(({ + id: value, name: label, + }) => ({ + value, + label, + })) || []; + }, + }, + attachments: { + type: "string", + label: "Attachments", + description: "A JSON-based array of structured attachments, presented as a URL-encoded string (e.g., `[{\"pretext\": \"pre-hello\", \"text\": \"text-world\"}]`).", + optional: true, + }, + unfurl_links: { + type: "boolean", + label: "Unfurl Links", + description: "Default to `false`. Pass `true` to enable unfurling of links.", + default: false, + optional: true, + }, + unfurl_media: { + type: "boolean", + label: "Unfurl Media", + description: "Defaults to `false`. Pass `true` to enable unfurling of media content.", + default: false, + optional: true, + }, + parse: { + type: "string", + label: "Parse", + description: "Change how messages are treated. Defaults to none. By default, URLs will be hyperlinked. Set `parse` to `none` to remove the hyperlinks. The behavior of `parse` is different for text formatted with `mrkdwn`. By default, or when `parse` is set to `none`, `mrkdwn` formatting is implemented. To ignore `mrkdwn` formatting, set `parse` to full.", + optional: true, + }, + as_user: { + type: "boolean", + label: "Send as User", + description: "Optionally pass `true` to post the message as the authenticated user, instead of as a bot. Defaults to `false`.", + default: false, + optional: true, + }, + mrkdwn: { + label: "Send text as Slack mrkdwn", + type: "boolean", + description: "`true` by default. Pass `false` to disable Slack markup parsing. [See docs here](https://api.slack.com/reference/surfaces/formatting)", + default: true, + optional: true, + }, + post_at: { + label: "Schedule message", + description: "Messages can only be scheduled up to 120 days in advance, and cannot be scheduled for the past. The datetime should be in ISO 8601 format. (Example: `2014-01-01T00:00:00Z`)", + type: "string", + optional: true, + }, + username: { + type: "string", + label: "Bot Username", + description: "Optionally customize your bot's user name (default is `Pipedream`). Must be used in conjunction with `Send as User` set to false, otherwise ignored.", + optional: true, + }, + blocks: { + type: "string", + label: "Blocks", + description: "Enter an array of [structured blocks](https://app.slack.com/block-kit-builder) as a URL-encoded string. E.g., `[{ \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \"This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and \" }}]`\n\n**Tip:** Construct your blocks in a code step, return them as an array, and then pass the return value to this step.", + optional: true, + }, + icon_emoji: { + type: "string", + label: "Icon (emoji)", + description: "Optionally provide an emoji to use as the icon for this message. E.g., `:fire:` Overrides `icon_url`. Must be used in conjunction with `Send as User` set to `false`, otherwise ignored.", + optional: true, + async options() { + return await this.getCustomEmojis({ + throwRateLimitError: true, + }); + }, + }, + content: { + label: "File Path or URL", + description: "The file to upload. Provide either a file URL or a path to a file in the `/tmp` directory (for example, `/tmp/myFile.txt`)", + type: "string", + }, + link_names: { + type: "boolean", + label: "Link Names", + description: "Find and link channel names and usernames.", + optional: true, + }, + thread_broadcast: { + type: "boolean", + label: "Send Channel Message", + description: "If `true`, posts in the thread and channel. Used in conjunction with `Message Timestamp` and indicates whether reply should be made visible to everyone in the channel. Defaults to `false`.", + default: false, + optional: true, + }, + reply_channel: { + label: "Reply Channel or Conversation ID", + type: "string", + description: "Provide the channel or conversation ID for the thread to reply to (e.g., if triggering on new Slack messages, enter `{{event.channel}}`). If the channel does not match the thread timestamp, a new message will be posted to this channel.", + optional: true, + }, + icon_url: { + type: "string", + label: "Icon (image URL)", + description: "Optionally provide an image URL to use as the icon for this message. Must be used in conjunction with `Send as User` set to `false`, otherwise ignored.", + optional: true, + }, + initial_comment: { + type: "string", + label: "Initial Comment", + description: "The message text introducing the file", + optional: true, + }, + email: { + type: "string", + label: "Email", + description: "An email address belonging to a user in the workspace", + }, + metadata_event_type: { + type: "string", + label: "Metadata Event Type", + description: "The name of the metadata event. Example: `task_created`", + optional: true, + }, + metadata_event_payload: { + type: "string", + label: "Metadata Event Payload", + description: "The payload of the metadata event. Must be a JSON string. Example: `{ \"id\": \"11223\", \"title\": \"Redesign Homepage\"}`", + optional: true, + }, + ignoreMyself: { + type: "boolean", + label: "Ignore myself", + description: "Ignore messages from me", + default: false, + }, + keyword: { + type: "string", + label: "Keyword", + description: "Keyword to monitor", + }, + ignoreBot: { + type: "boolean", + label: "Ignore Bots", + description: "Ignore messages from bots", + default: false, + optional: true, + }, + resolveNames: { + type: "boolean", + label: "Resolve Names", + description: "Instead of returning `channel`, `team`, and `user` as IDs, return their human-readable names.", + default: false, + optional: true, + }, + pageSize: { + type: "integer", + label: "Page Size", + description: "The number of results to include in a page. Default: 250", + default: constants.LIMIT, + optional: true, + }, + numPages: { + type: "integer", + label: "Number of Pages", + description: "The number of pages to retrieve. Default: 1", + default: 1, + optional: true, + }, + addToChannel: { + type: "boolean", + label: "Add app to channel automatically?", + description: "If `true`, the app will be added to the specified non-DM channel(s) automatically. If `false`, you must add the app to the channel manually. Defaults to `true`.", + default: true, + }, + }, + methods: { + getChannelLabel(resource) { + if (resource.user) { + return `Direct Messaging with: @${resource.user.name}`; + } + + const { + is_private: isPrivate, + name, + } = resource.channel; + + return `${isPrivate && "Private" || "Public"} channel #${name}`; + }, + mySlackId() { + return this.$auth.oauth_uid; + }, + getToken(opts = {}) { + return (opts.as_bot && this.$auth.bot_token) + ? this.$auth.bot_token + : this.$auth.oauth_access_token; + }, + async getChannelDisplayName(channel) { + if (channel.user) { + try { + const { profile } = await this.getUserProfile({ + user: channel.user, + }); + return `@${profile.real_name || profile?.real_name}`; + } catch { + return "user"; + } + } else if (channel.is_mpim) { + try { + const { members } = await this.listChannelMembers({ + channel: channel.id, + }); + const users = await Promise.all(members.map((m) => this.getUserProfile({ + user: m, + }))); + const realNames = users.map((u) => u.profile?.real_name || u.real_name); + return `Group Messaging with: ${realNames.join(", ")}`; + } catch { + return `Group Messaging with: ${channel.purpose.value}`; + } + } + return `#${channel?.name}`; + }, + /** + * Returns a Slack Web Client object authenticated with the user's access + * token + */ + sdk(opts = {}) { + return new WebClient(this.getToken(opts), { + rejectRateLimitedCalls: true, + }); + }, + async makeRequest({ + method = "", throwRateLimitError = false, as_user, as_bot, ...args + } = {}) { + as_bot = as_user === false || as_bot; + + const props = method.split("."); + const sdk = props.reduce((reduction, prop) => + reduction[prop], this.sdk({ + as_bot, + })); + + let response; + try { + response = await this._withRetries(() => sdk(args), throwRateLimitError); + } catch (error) { + if ([ + "not_in_channel", + "channel_not_found", + ].includes(error?.data?.error) && as_bot) { + // If method starts with chat, include the part about "As User" + // Otherwise, just say "Ensure the bot is a member of the channel" + if (method.startsWith("chat.")) { + throw new ConfigurationError(`${error} + Ensure the bot is a member of the channel, or set the **Send as User** option to true to act on behalf of the authenticated user. + `); + } + throw new ConfigurationError(`${error} + Ensure the bot is a member of the channel. + `); + } + throw `${error}`; + } + + if (!response.ok) { + throw response.error; + } + return response; + }, + async _withRetries(apiCall, throwRateLimitError = false) { + const retryOpts = { + retries: 3, + minTimeout: 30000, + }; + return retry(async (bail) => { + try { + return await apiCall(); + } catch (error) { + const statusCode = get(error, "code"); + if (statusCode === "slack_webapi_rate_limited_error") { + if (throwRateLimitError) { + bail(new Error(`Rate limit exceeded. ${error}`)); + } else { + console.log(`Rate limit exceeded. Will retry in ${retryOpts.minTimeout / 1000} seconds`); + throw error; + } + } + bail(error); + } + }, retryOpts); + }, + /** + * Returns a list of channel-like conversations in a workspace. The + * "channels" returned depend on what the calling token has access to and + * the directives placed in the types parameter. + * + * @param {string} [types] - a comma-separated list of channel types to get. + * Any combination of: `public_channel`, `private_channel`, `mpim`, `im` + * @param {string} [cursor] - a cursor returned by the previous API call, + * used to paginate through collections of data + * @returns an object containing a list of conversations and the cursor for the next + * page of conversations + */ + async availableConversations(types, cursor, throwRateLimitError = false) { + const { + channels: conversations, + response_metadata: { next_cursor: nextCursor }, + } = await this.usersConversations({ + types, + cursor, + limit: constants.LIMIT, + exclude_archived: true, + throwRateLimitError, + }); + return { + cursor: nextCursor, + conversations, + }; + }, + async userNameLookup(ids = [], throwRateLimitError = true, args = {}) { + let cursor; + const userNames = {}; + do { + const { + members: users, + response_metadata: { next_cursor: nextCursor }, + } = await this.usersList({ + limit: constants.LIMIT, + cursor, + throwRateLimitError, + ...args, + }); + + for (const user of users) { + if (ids.includes(user.id)) { + userNames[user.id] = user.name; + } + } + + cursor = nextCursor; + } while (cursor && Object.keys(userNames).length < ids.length); + return userNames; + }, + async realNameLookup(ids = [], usernames = [], throwRateLimitError = true, args = {}) { + let cursor; + const realNames = {}; + do { + const { + members: users, + response_metadata: { next_cursor: nextCursor }, + } = await this.usersList({ + limit: constants.LIMIT, + cursor, + throwRateLimitError, + ...args, + }); + + for (const user of users) { + if (ids.includes(user.id)) { + realNames[user.id] = user.profile.real_name; + } + if (usernames.includes(user.name)) { + realNames[user.name] = user.profile.real_name; + } + } + + cursor = nextCursor; + } while (cursor && Object.keys(realNames).length < (ids.length + usernames.length)); + return realNames; + }, + async maybeAddAppToChannels(channelIds = []) { + if (!this.$auth.bot_token) return; + const { + bot_id, user_id, + } = await this.authTest({ + as_bot: true, + }); + if (!bot_id) { + throw new Error("Could not get bot ID. Make sure the Slack app has a bot user."); + } + // XXX: Trying to add the app to DM or group DM channels results in the + // error: method_not_supported_for_channel_type + for (const channel of channelIds) { + try { + await this.inviteToConversation({ + channel, + users: user_id, + }); + } catch (error) { + if (![ + "method_not_supported_for_channel_type", + "already_in_channel", + ].some((msg) => (error.data?.error || error.message || error)?.includes(msg))) { + throw error; + } + } + } + }, + async checkAccessToChannel(channelId) { + // If not using a bot token, skip this check + if (!this.$auth.bot_token) return; + if (!channelId.startsWith("U") && !channelId.startsWith("D")) { + // If not a DM, check if the user is a member of the channel + return await this.conversationsInfo({ + channel: channelId, + throwRateLimitError: true, + }); + } else { + // If a DM, check if the user has a valid token + await this.authTest({ + throwRateLimitError: true, + }); + } + }, + /** + * Checks authentication & identity. + * @param {*} args Arguments object + * @returns Promise + */ + authTest(args = {}) { + return this.makeRequest({ + method: "auth.test", + ...args, + }); + }, + /** + * Lists all reminders created by or for a given user. + * @param {*} args Arguments object + * @param {string} [args.team_id] Encoded team id, required if org token is passed. + * E.g. `T1234567890` + * @returns Promise + */ + remindersList(args = {}) { + return this.makeRequest({ + method: "reminders.list", + ...args, + }); + }, + /** + * List all User Groups for a team + * @param {*} args + * @returns Promise + */ + usergroupsList(args = {}) { + return this.makeRequest({ + method: "usergroups.list", + ...args, + }); + }, + authTeamsList(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "auth.teams.list", + ...args, + }); + }, + /** + * List conversations the calling user may access. + * Bot Scopes: `channels:read` `groups:read` `im:read` `mpim:read` + * @param {UsersConversationsArguments} args Arguments object + * @param {string} [args.cursor] Pagination value e.g. (`dXNlcjpVMDYxTkZUVDI=`) + * @param {boolean} [args.exclude_archived] Set to `true` to exclude archived channels + * from the list. Defaults to `false` + * @param {number} [args.limit] Pagination value. Defaults to `250` + * @param {string} [args.team_id] Encoded team id to list users in, + * required if org token is used + * @param {string} [args.types] Mix and match channel types by providing a + * comma-separated list of any combination of `public_channel`, `private_channel`, `mpim`, `im` + * Defaults to `public_channel`. E.g. `im,mpim` + * @param {string} [args.user] Browse conversations by a specific + * user ID's membership. Non-public channels are restricted to those where the calling user + * shares membership. E.g `W0B2345D` + * @returns Promise + */ + usersConversations(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "users.conversations", + user: this.$auth.oauth_uid, + ...args, + }); + }, + /** + * Lists all users in a Slack team. + * Bot Scopes: `users:read` + * @param {UsersListArguments} args Arguments object + * @param {string} [args.cursor] Pagination value e.g. (`dXNlcjpVMDYxTkZUVDI=`) + * @param {boolean} [args.include_locale] Set this to `true` to receive the locale + * for users. Defaults to `false` + * @param {number} [args.limit] The maximum number of items to return. Defaults to `250` + * @param {string} [args.team_id] Encoded team id to list users in, + * required if org token is used + * @returns Promise + */ + usersList(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "users.list", + ...args, + }); + }, + /** + * Lists all channels in a Slack team. + * Bot Scopes: `channels:read` `groups:read` `im:read` `mpim:read` + * @param {ConversationsListArguments} args Arguments object + * @param {string} [args.cursor] Pagination value e.g. (`dXNlcjpVMDYxTkZUVDI=`) + * @param {boolean} [args.exclude_archived] Set to `true` to exclude archived channels + * from the list. Defaults to `false` + * @param {number} [args.limit] pagination value. Defaults to `250` + * @param {string} [args.team_id] encoded team id to list users in, + * required if org token is used + * @param {string} [args.types] Mix and match channel types by providing a + * comma-separated list of any combination of `public_channel`, `private_channel`, `mpim`, `im` + * Defaults to `public_channel`. E.g. `im,mpim` + * @returns Promise + */ + conversationsList(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "conversations.list", + ...args, + }); + }, + /** + * Fetches a conversation's history of messages and events. + * Bot Scopes: `channels:history` `groups:history` `im:history` `mpim:history` + * @param {ConversationsHistoryArguments} args Arguments object + * @param {string} args.channel Conversation ID to fetch history for. E.g. `C1234567890` + * @param {string} [args.cursor] Pagination value e.g. (`dXNlcjpVMDYxTkZUVDI=`) + * @param {boolean} [args.include_all_metadata] + * @param {boolean} [args.inclusive] + * @param {string} [args.latest] + * @param {number} [args.limit] + * @param {string} [args.oldest] + * @returns Promise + */ + conversationsHistory(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "conversations.history", + ...args, + }); + }, + /** + * Retrieve information about a conversation. + * Bot Scopes: `channels:read` `groups:read` `im:read` `mpim:read` + * @param {ConversationsInfoArguments} args Arguments object + * @param {string} args.channel Conversation ID to learn more about. E.g. `C1234567890` + * @param {boolean} [args.include_locale] Set this to `true` to receive the locale + * for users. Defaults to `false` + * @param {boolean} [args.include_num_members] Set to true to include the + * member count for the specified conversation. Defaults to `false` + * @returns Promise + */ + conversationsInfo(args = {}) { + return this.makeRequest({ + method: "conversations.info", + ...args, + }); + }, + /** + * Retrieve information about a conversation. + * Bot Scopes: `users:read` + * @param {UsersInfoArguments} args arguments object + * @param {string} args.user User to get info on. E.g. `W1234567890` + * @param {boolean} [args.include_locale] Set this to true to receive the locale + * for this user. Defaults to `false` + * @returns Promise + */ + usersInfo(args = {}) { + return this.makeRequest({ + method: "users.info", + ...args, + }); + }, + /** + * Searches for messages matching a query. + * User Scopes: `search:read` + * @param {SearchMessagesArguments} args Arguments object + * @param {string} args.query Search query + * @param {number} [args.count] Number of items to return per page. Default `250` + * @param {string} [args.cursor] Use this when getting results with cursormark + * pagination. For first call send `*` for subsequent calls, send the value of + * `next_cursor` returned in the previous call's results + * @param {boolean} [args.highlight] + * @param {number} [args.page] + * @param {string} [args.sort] + * @param {string} [args.sort_dir] + * @param {string} [args.team_id] Encoded team id to search in, + * required if org token is used. E.g. `T1234567890` + * @returns Promise + */ + searchMessages(args = {}) { + args.count ||= constants.LIMIT; + return this.makeRequest({ + method: "search.messages", + ...args, + }); + }, + /** + * Lists reactions made by a user. + * User Scopes: `reactions:read` + * Bot Scopes: `reactions:read` + * @param {ReactionsListArguments} args Arguments object + * @param {number} [args.count] Number of items to return per page. Default `100` + * @param {string} [args.cursor] Parameter for pagination. Set cursor equal to the + * `next_cursor` attribute returned by the previous request's response_metadata. + * This parameter is optional, but pagination is mandatory: the default value simply + * fetches the first "page" of the collection. + * @param {boolean} [args.full] If true always return the complete reaction list. + * @param {number} [args.limit] The maximum number of items to return. + * Fewer than the requested number of items may be returned, even if the end of the + * list hasn't been reached. + * @param {number} [args.page] Page number of results to return. Defaults to `1`. + * @param {string} [args.team_id] Encoded team id to list reactions in, + * required if org token is used + * @param {string} [args.user] Show reactions made by this user. Defaults to the authed user. + * @returns Promise + */ + reactionsList(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "reactions.list", + ...args, + }); + }, + async getCustomEmojis(args = {}) { + const resp = await this.sdk().emoji.list({ + include_categories: true, + limit: constants.LIMIT, + ...args, + }); + + const emojis = Object.keys(resp.emoji); + for (const category of resp.categories) { + emojis.push(...category.emoji_names); + } + return emojis; + }, + listChannelMembers(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "conversations.members", + ...args, + }); + }, + listFiles(args = {}) { + args.count ||= constants.LIMIT; + return this.makeRequest({ + method: "files.list", + as_bot: true, + ...args, + }); + }, + listGroupMembers(args = {}) { + args.limit ||= constants.LIMIT; + return this.makeRequest({ + method: "usergroups.users.list", + ...args, + }); + }, + getFileInfo(args = {}) { + return this.makeRequest({ + method: "files.info", + as_bot: true, + ...args, + }); + }, + getUserProfile(args = {}) { + return this.makeRequest({ + method: "users.profile.get", + ...args, + }); + }, + getBotInfo(args = {}) { + return this.makeRequest({ + method: "bots.info", + ...args, + }); + }, + getTeamInfo(args = {}) { + return this.makeRequest({ + method: "team.info", + ...args, + }); + }, + getConversationReplies(args = {}) { + return this.makeRequest({ + method: "conversations.replies", + ...args, + }); + }, + addReactions(args = {}) { + return this.makeRequest({ + method: "reactions.add", + ...args, + }); + }, + postChatMessage(args = {}) { + return this.makeRequest({ + method: "chat.postMessage", + ...args, + }); + }, + archiveConversations(args = {}) { + return this.makeRequest({ + method: "conversations.archive", + ...args, + }); + }, + scheduleMessage(args = {}) { + return this.makeRequest({ + method: "chat.scheduleMessage", + ...args, + }); + }, + createConversations(args = {}) { + return this.makeRequest({ + method: "conversations.create", + ...args, + }); + }, + inviteToConversation(args = {}) { + return this.makeRequest({ + method: "conversations.invite", + ...args, + }); + }, + kickUserFromConversation(args = {}) { + return this.makeRequest({ + method: "conversations.kick", + ...args, + }); + }, + addReminders(args = {}) { + return this.makeRequest({ + method: "reminders.add", + ...args, + }); + }, + deleteFiles(args = {}) { + return this.makeRequest({ + method: "files.delete", + ...args, + }); + }, + deleteMessage(args = {}) { + return this.makeRequest({ + method: "chat.delete", + ...args, + }); + }, + lookupUserByEmail(args = {}) { + return this.makeRequest({ + method: "users.lookupByEmail", + ...args, + }); + }, + setChannelDescription(args = {}) { + return this.makeRequest({ + method: "conversations.setPurpose", + ...args, + }); + }, + setChannelTopic(args = {}) { + return this.makeRequest({ + method: "conversations.setTopic", + ...args, + }); + }, + updateProfile(args = {}) { + return this.makeRequest({ + method: "users.profile.set", + ...args, + }); + }, + updateGroupMembers(args = {}) { + return this.makeRequest({ + method: "usergroups.users.update", + ...args, + }); + }, + updateMessage(args = {}) { + return this.makeRequest({ + method: "chat.update", + ...args, + }); + }, + getUploadUrl(args = {}) { + return this.makeRequest({ + method: "files.getUploadURLExternal", + ...args, + }); + }, + completeUpload(args = {}) { + return this.makeRequest({ + method: "files.completeUploadExternal", + ...args, + }); + }, + }, +}; diff --git a/components/slack_v2_test/sources/common/base.mjs b/components/slack_v2_test/sources/common/base.mjs new file mode 100644 index 0000000000000..fdffe795a98ba --- /dev/null +++ b/components/slack_v2_test/sources/common/base.mjs @@ -0,0 +1,184 @@ +import slack from "../../slack_v2_test.app.mjs"; +import { + NAME_CACHE_MAX_SIZE, NAME_CACHE_TIMEOUT, +} from "./constants.mjs"; + +export default { + props: { + slack, + db: "$.service.db", + }, + methods: { + _getNameCache() { + return this.db.get("nameCache") ?? {}; + }, + _setNameCache(cacheObj) { + this.db.set("nameCache", cacheObj); + }, + _getLastCacheCleanup() { + return this.db.get("lastCacheCleanup") ?? 0; + }, + _setLastCacheCleanup(time) { + this.db.set("lastCacheCleanup", time); + }, + cleanCache(cacheObj) { + console.log("Initiating cache check-up..."); + const timeout = Date.now() - NAME_CACHE_TIMEOUT; + + const entries = Object.entries(cacheObj); + let cleanArr = entries.filter( + ([ + , { ts }, + ]) => ts > timeout, + ); + const diff = entries.length - cleanArr.length; + if (diff) { + console.log(`Cleaned up ${diff} outdated cache entries.`); + } + + if (cleanArr.length > NAME_CACHE_MAX_SIZE) { + console.log(`Reduced the cache from ${cleanArr.length} to ${NAME_CACHE_MAX_SIZE / 2} entries.`); + cleanArr = cleanArr.slice(NAME_CACHE_MAX_SIZE / -2); + } + + const cleanObj = Object.fromEntries(cleanArr); + return cleanObj; + }, + getCache() { + let cacheObj = this._getNameCache(); + + const lastCacheCleanup = this._getLastCacheCleanup(); + const time = Date.now(); + + const shouldCleanCache = time - lastCacheCleanup > NAME_CACHE_TIMEOUT / 2; + if (shouldCleanCache) { + cacheObj = this.cleanCache(cacheObj); + this._setLastCacheCleanup(time); + } + + return [ + cacheObj, + shouldCleanCache, + ]; + }, + async maybeCached(key, refreshVal) { + let [ + cacheObj, + wasUpdated, + ] = this.getCache(); + let record = cacheObj[key]; + const time = Date.now(); + if (!record || time - record.ts > NAME_CACHE_TIMEOUT) { + record = { + ts: time, + val: await refreshVal(), + }; + cacheObj[key] = record; + wasUpdated = true; + } + + if (wasUpdated) { + this._setNameCache(cacheObj); + } + + return record.val; + }, + async getUserName(id) { + return this.maybeCached(`users:${id}`, async () => { + const info = await this.slack.usersInfo({ + user: id, + }); + if (!info.ok) throw new Error(info.error); + return info.user.name; + }); + }, + async getRealName(id) { + return this.maybeCached(`users_real_names:${id}`, async () => { + const info = await this.slack.usersInfo({ + user: id, + }); + if (!info.ok) throw new Error(info.error); + return info.user.real_name; + }); + }, + async getBotName(id) { + return this.maybeCached(`bots:${id}`, async () => { + const info = await this.slack.getBotInfo({ + bot: id, + }); + if (!info.ok) throw new Error(info.error); + return info.bot.name; + }); + }, + async getConversationName(id) { + return this.maybeCached(`conversations:${id}`, async () => { + const info = await this.slack.conversationsInfo({ + channel: id, + }); + if (!info.ok) throw new Error(info.error); + if (info.channel.is_im) { + return `DM with ${await this.getUserName(info.channel.user)}`; + } + return info.channel.name; + }); + }, + async getTeamName(id) { + return this.maybeCached(`team:${id}`, async () => { + try { + const info = await this.slack.getTeamInfo({ + team: id, + }); + return info.team.name; + } catch (err) { + console.log( + "Error getting team name, probably need to re-connect the account at pipedream.com/apps", + err, + ); + return id; + } + }); + }, + async getMessage({ + channel, event_ts: ts, + }) { + return await this.maybeCached( + `lastMessage:${channel}:${ts}`, + async () => { + const response = await this.slack.getConversationReplies({ + channel, + ts, + limit: 1, + }); + + if (response.messages.length) { + response.messages = [ + response.messages[0], + ]; + } + + return response; + }, + ); + }, + processEvent(event) { + return event; + }, + }, + async run(event) { + event = await this.processEvent(event); + + if (event) { + if (!event.client_msg_id) { + event.pipedream_msg_id = `pd_${Date.now()}_${Math.random() + .toString(36) + .substr(2, 10)}`; + } + + this.$emit(event, { + id: event.client_msg_id || event.pipedream_msg_id || event.channel.id, + summary: this.getSummary(event), + ts: event.event_ts || Date.now(), + }); + } + }, +}; diff --git a/components/slack_v2_test/sources/common/constants.mjs b/components/slack_v2_test/sources/common/constants.mjs new file mode 100644 index 0000000000000..3255f46788c83 --- /dev/null +++ b/components/slack_v2_test/sources/common/constants.mjs @@ -0,0 +1,58 @@ +const events = { + im: "User", + message: "Message", + file: "File", + channel: "Channel", +}; + +const eventsOptions = [ + { + label: "User", + value: "im", + }, + { + label: "Message", + value: "message", + }, + { + label: "File", + value: "file", + }, + { + label: "Channel", + value: "channel", + }, +]; + +const SUBTYPE = { + NULL: null, + BOT_MESSAGE: "bot_message", + FILE_SHARE: "file_share", + PD_HISTORY_MESSAGE: "pd_history_message", + MESSAGE_REPLIED: "message_replied", +}; + +const ALLOWED_SUBTYPES = [ + SUBTYPE.NULL, + SUBTYPE.BOT_MESSAGE, + SUBTYPE.FILE_SHARE, + SUBTYPE.PD_HISTORY_MESSAGE, +]; + +const ALLOWED_MESSAGE_IN_CHANNEL_SUBTYPES = [ + SUBTYPE.NULL, + SUBTYPE.BOT_MESSAGE, + SUBTYPE.FILE_SHARE, + SUBTYPE.MESSAGE_REPLIED, +]; + +export const NAME_CACHE_MAX_SIZE = 1000; +export const NAME_CACHE_TIMEOUT = 3600000; + +export default { + events, + eventsOptions, + SUBTYPE, + ALLOWED_SUBTYPES, + ALLOWED_MESSAGE_IN_CHANNEL_SUBTYPES, +}; diff --git a/components/slack_v2_test/sources/new-channel-created/new-channel-created.mjs b/components/slack_v2_test/sources/new-channel-created/new-channel-created.mjs new file mode 100644 index 0000000000000..6314d72c5f631 --- /dev/null +++ b/components/slack_v2_test/sources/new-channel-created/new-channel-created.mjs @@ -0,0 +1,32 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-channel-created", + name: "New Channel Created (Instant)", + version: "0.0.12", + description: "Emit new event when a new channel is created.", + type: "source", + dedupe: "unique", + props: { + ...common.props, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return [ + "channel_created", + ]; + }, + }, + }, + methods: { + ...common.methods, + getSummary({ channel: { name } }) { + return `New channel created - ${name}`; + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-channel-created/test-event.mjs b/components/slack_v2_test/sources/new-channel-created/test-event.mjs new file mode 100644 index 0000000000000..6bcfee6fd7deb --- /dev/null +++ b/components/slack_v2_test/sources/new-channel-created/test-event.mjs @@ -0,0 +1,44 @@ +export default { + "type": "channel_created", + "channel": { + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "is_group": false, + "is_im": false, + "is_mpim": false, + "is_private": false, + "created": 1360782804, + "is_archived": false, + "is_general": false, + "unlinked": 0, + "name_normalized": "fun", + "is_shared": false, + "is_frozen": false, + "is_org_shared": false, + "is_pending_ext_shared": false, + "pending_shared": [], + "context_team_id": "TLZ203R5", + "updated": 1714140253251, + "parent_conversation": null, + "creator": "U024BE7LH", + "is_ext_shared": false, + "shared_team_ids": [ + "TLZ203R5" + ], + "pending_connected_team_ids": [], + "topic": { + "value": "", + "creator": "", + "last_set": 0 + }, + "purpose": { + "value": "", + "creator": "", + "last_set": 0 + }, + "previous_names": [] + }, + "event_ts": "1714140253.002700", + "pipedream_msg_id": "pd_1714140255038_bkbl3pxpkp" +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-interaction-event-received/README.md b/components/slack_v2_test/sources/new-interaction-event-received/README.md new file mode 100644 index 0000000000000..4cb59ba41fe5e --- /dev/null +++ b/components/slack_v2_test/sources/new-interaction-event-received/README.md @@ -0,0 +1,85 @@ +# Overview + +Slack messages can contain interactive elements like buttons, dropdowns, radio buttons, and more. This source subscribes to interactive events, like when a button is clicked in a message. + +![Example of a Slack button](https://res.cloudinary.com/pipedreamin/image/upload/v1668443788/docs/components/CleanShot_2022-11-10_at_10.17.172x_dxdz1o.png) + +Then this source will be triggered when you or another Slack user in your workspace clicks a button, selects an option or fills out a form. + +![Example feed of interaction events coming from Slack](https://res.cloudinary.com/pipedreamin/image/upload/v1668443818/docs/components/CleanShot_2022-11-10_at_10.19.152x_eyiims.png) + +With this trigger, you can build workflows that perform some work with other APIs or services, and then reply back to the original message. + +# Getting Started + + + +What this short video to learn how to use this in a workflow, or follow the guide below. + +First, if you haven’t already - send yourself a message containing one or more interactive elements. Use the ******************Sending the message with an interactive element****************** guide below to send a message containing a button. + +If you have already sent a message containing an element, skip to **********************************************Configuring the source.********************************************** + +## Sending the message with an interactive element + +The easiest way is to send yourself a message using the ****************************Slack - Send Message Using Block Kit**************************** action: + +![Selecting the Send Slack Message with Block Kit](https://res.cloudinary.com/pipedreamin/image/upload/v1668443844/docs/components/CleanShot_2022-11-10_at_10.25.522x_vxiooo.png)) + +Then select a **************Channel************** you’d like to send the message to, and use the **************[Block Kit Builder](https://app.slack.com/block-kit-builder/)************** to build a message, or just copy the example button blocks below: + +```jsx +[ + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123", + "action_id": "button_click" + } + ] + } +] +``` + +Your ******************Slack - Send Message Using Block Kit****************** should look like this: + +![Setting up the block kit message with a button block](https://res.cloudinary.com/pipedreamin/image/upload/v1668443887/docs/components/CleanShot_2022-11-10_at_10.29.552x_kvfznm.png) + +## Configuring the source + +By default, this source will listen to ******all****** interactive events from your Slack workspace that your connected Slack account has authorization to view. Please note that only messages created via [Slack - Send Block Kit Message](https://pipedream.com/apps/slack/actions/send-block-kit-message) Action, or via API call from the Pipedream app will emit an interaction event with this trigger. Block kit messages sent directly via the Slack's block kit builder will not trigger an interaction event. + +You can filter these events by selecting a specific **************channel************** and/or a specific **********action_id.********** + +### Filtering interactive events by channel + +Use the ****************Channels**************** dropdown to search for a specific channel for this source to subscribe to. ********Only******** button clicks, dropdown selects, etc. *in this selected channel* will trigger the source. + +### Filtering interactive events by `action_id` + +For more specificity, you can filter based on the passed `action_id` to the message. + +The `action_id` is arbitrary. It’s defined on the initial message sending the button, dropdown, or other interactive element’s markup. + +For example, in the section above using the Block Kit to create a message, we defined the button’s `action_id` as `"button_click"`. But you can choose whichever naming convention you’d like. + +If you pass `button_click` as a required `action_id` to this source, then only interactivity events with the `action_id` of `"button_click"` will trigger this source. + +## Troubleshooting + +### I’m clicking buttons, but no events are being received + +Follow these steps to make sure your source is configured correctly: + +1. Make sure that your `action_id` or ****************channels**************** filters apply to that message, remove the filters to make sure that’s not the case. + +1. Make sure that the message comes from the same Slack account that this source is configured with. + +1. Make sure that the message was sent via Pipedream action (e.g. [Slack - Send Block Kit Message](https://pipedream.com/apps/slack/actions/send-block-kit-message) Action) or via API call from the Pipedream app. diff --git a/components/slack_v2_test/sources/new-interaction-event-received/new-interaction-event-received.mjs b/components/slack_v2_test/sources/new-interaction-event-received/new-interaction-event-received.mjs new file mode 100644 index 0000000000000..323b351cf1a83 --- /dev/null +++ b/components/slack_v2_test/sources/new-interaction-event-received/new-interaction-event-received.mjs @@ -0,0 +1,105 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + name: "New Interaction Events (Instant)", + version: "0.0.22", + key: "slack_v2_test-new-interaction-event-received", + description: "Emit new events on new Slack [interactivity events](https://api.slack.com/interactivity) sourced from [Block Kit interactive elements](https://api.slack.com/interactivity/components), [Slash commands](https://api.slack.com/interactivity/slash-commands), or [Shortcuts](https://api.slack.com/interactivity/shortcuts).", + type: "source", + props: { + ...common.props, + alert: { + type: "alert", + alertType: "info", + content: "Please note that only messages created via Pipedream's [Send Block Kit Message](https://pipedream.com/apps/slack/actions/send-block-kit-message) Action, or via API call from the Pipedream app will emit an interaction event with this trigger. \n\nBlock kit messages sent directly via the Slack's block kit builder will not trigger an interaction event. \n\nSee the [documentation](https://pipedream.com/apps/slack/triggers/new-interaction-event-received) for more details.", + }, + action_ids: { + type: "string[]", + label: "Action IDs", + description: "Filter interaction events by specific `action_id`'s to subscribe for new interaction events. If none are specified, all `action_ids` created via Pipedream will emit new events.", + optional: true, + default: [], + }, + conversations: { + propDefinition: [ + common.props.slack, + "conversation", + ], + type: "string[]", + label: "Channels", + description: "Filter interaction events by one or more channels. If none selected, any interaction event in any channel will emit new events.", + optional: true, + default: [], + }, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + /** + * Subscribes to potentially 4 different events: + * `interaction_events` - all interaction events on the authenticated account + * `interaction_events:${action_id}` - all interaction events with a specific given action_id + * `interaction_events:${channel_id}` - all interaction events within a specific channel + * `interaction_events:${channel_id}:${action_id}` - action_id within a specific channel + * @returns string[] + */ + async eventNames() { + // start with action_ids, since they can be the most specific + const action_events = this.action_ids.reduce((carry, action_id) => { + // if channels are provided, spread them + if (this.conversations && this.conversations.length > 0) { + return [ + ...carry, + ...this.conversations.map( + (channel) => `interaction_events:${channel}:${action_id}`, + ), + ]; + } + + return [ + ...carry, + `interaction_events:${action_id}`, + ]; + }, []); + + if (action_events.length > 0) return action_events; + + // if no action_ids are specified, move down to channels + const channel_events = this.conversations.map( + (channel) => `interaction_events:${channel}`, + ); + + if (channel_events.length > 0) return channel_events; + + // if not specific action_ids or channels are specified, subscribe to all events + return [ + "interaction_events", + ]; + }, + }, + }, + methods: {}, + async run(event) { + this.$emit( + { + event, + }, + { + summary: `New interaction event${ + event?.channel?.id + ? ` in channel ${event.channel.id}` + : "" + }${ + event.actions?.length > 0 + ? ` from action_ids ${event.actions + .map((action) => action.action_id) + .join(", ")}` + : "" + }`, + ts: Date.now(), + }, + ); + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-interaction-event-received/test-event.mjs b/components/slack_v2_test/sources/new-interaction-event-received/test-event.mjs new file mode 100644 index 0000000000000..940a52c301ea8 --- /dev/null +++ b/components/slack_v2_test/sources/new-interaction-event-received/test-event.mjs @@ -0,0 +1,86 @@ +export default { + "event": { + "type": "block_actions", + "user": { + "id": "US676PZLY", + "username": "test.user", + "name": "test.user", + "team_id": "TS8319547" + }, + "api_app_id": "AN9231S6L", + "token": "UYc82mtyZWRhvUXQ6TXrv4wq", + "container": { + "type": "message", + "message_ts": "1716402983.247149", + "channel_id": "CS8319KD5", + "is_ephemeral": false + }, + "trigger_id": "7161731794692.892103311143.4020ed3595908eca11e4076438354dbb", + "team": { + "id": "TS8319547", + "domain": "test-j1q3506" + }, + "enterprise": null, + "is_enterprise_install": false, + "channel": { + "id": "CS8319KD5", + "name": "testing" + }, + "message": { + "subtype": "bot_message", + "text": "Click Me button Sent via ", + "username": "Pipedream", + "type": "message", + "ts": "1716402983.247149", + "bot_id": "BRTDL45RQ", + "app_id": "AN9231S6L", + "blocks": [ + { + "type": "actions", + "block_id": "SJp0j", + "elements": [ + { + "type": "button", + "action_id": "button_click", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123" + } + ] + }, + { + "type": "context", + "block_id": "ysmBN", + "elements": [ + { + "type": "mrkdwn", + "text": "Sent via ", + "verbatim": false + } + ] + } + ] + }, + "state": { + "values": {} + }, + "response_url": "https://hooks.slack.com/actions/TS8319547/7156351250101/J0w1NoVIXjChEwp4WQab4tcv", + "actions": [ + { + "action_id": "button_click", + "block_id": "SJp0j", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123", + "type": "button", + "action_ts": "1716403200.549150" + } + ] + } +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-keyword-mention/new-keyword-mention.mjs b/components/slack_v2_test/sources/new-keyword-mention/new-keyword-mention.mjs new file mode 100644 index 0000000000000..c0ee0f8f66888 --- /dev/null +++ b/components/slack_v2_test/sources/new-keyword-mention/new-keyword-mention.mjs @@ -0,0 +1,99 @@ +import common from "../common/base.mjs"; +import constants from "../common/constants.mjs"; +import sampleEmit from "./test-event.mjs"; +import sharedConstants from "../../common/constants.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-keyword-mention", + name: "New Keyword Mention (Instant)", + version: "0.0.11", + description: "Emit new event when a specific keyword is mentioned in a channel", + type: "source", + dedupe: "unique", + props: { + ...common.props, + conversations: { + propDefinition: [ + common.props.slack, + "conversation", + () => ({ + types: [ + sharedConstants.CHANNEL_TYPE.PUBLIC, + sharedConstants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + type: "string[]", + label: "Channels", + description: "Select one or more channels to monitor for new messages.", + optional: true, + }, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return this.conversations || [ + "message", + ]; + }, + }, + keyword: { + propDefinition: [ + common.props.slack, + "keyword", + ], + }, + ignoreBot: { + propDefinition: [ + common.props.slack, + "ignoreBot", + ], + }, + }, + methods: { + ...common.methods, + getSummary() { + return "New keyword mention received"; + }, + async processEvent(event) { + const { + type: msgType, + subtype, + bot_id: botId, + text, + } = event; + + if (msgType !== "message") { + console.log(`Ignoring event with unexpected type "${msgType}"`); + return; + } + + // This source is designed to just emit an event for each new message received. + // Due to inconsistencies with the shape of message_changed and message_deleted + // events, we are ignoring them for now. If you want to handle these types of + // events, feel free to change this code!! + if (subtype && !constants.ALLOWED_SUBTYPES.includes(subtype)) { + console.log(`Ignoring message with subtype. "${subtype}"`); + return; + } + + if ((this.ignoreBot) && (subtype === constants.SUBTYPE.BOT_MESSAGE || botId)) { + return; + } + + let emitEvent = false; + if (text.indexOf(this.keyword) !== -1) { + emitEvent = true; + } else if (subtype === constants.SUBTYPE.PD_HISTORY_MESSAGE) { + emitEvent = true; + } + + if (emitEvent) { + return event; + } + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-keyword-mention/test-event.mjs b/components/slack_v2_test/sources/new-keyword-mention/test-event.mjs new file mode 100644 index 0000000000000..7c85b12599e3d --- /dev/null +++ b/components/slack_v2_test/sources/new-keyword-mention/test-event.mjs @@ -0,0 +1,28 @@ +export default { + "user": "US676PZLY", + "type": "message", + "ts": "1716404766.096289", + "client_msg_id": "b26387fd-5afe-46a9-bf63-a7aabd6fb40f", + "text": "hello", + "team": "TS8319547", + "blocks": [ + { + "type": "rich_text", + "block_id": "aY6KK", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "hello" + } + ] + } + ] + } + ], + "channel": "CS8319KD5", + "event_ts": "1716404766.096289", + "channel_type": "channel" +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-message-in-channels/new-message-in-channels.mjs b/components/slack_v2_test/sources/new-message-in-channels/new-message-in-channels.mjs new file mode 100644 index 0000000000000..dc032fadd2b03 --- /dev/null +++ b/components/slack_v2_test/sources/new-message-in-channels/new-message-in-channels.mjs @@ -0,0 +1,106 @@ +import common from "../common/base.mjs"; +import constants from "../common/constants.mjs"; +import sampleEmit from "./test-event.mjs"; +import sharedConstants from "../../common/constants.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-message-in-channels", + name: "New Message In Channels (Instant)", + version: "1.0.28", + description: "Emit new event when a new message is posted to one or more channels", + type: "source", + dedupe: "unique", + props: { + ...common.props, + conversations: { + propDefinition: [ + common.props.slack, + "conversation", + () => ({ + types: [ + sharedConstants.CHANNEL_TYPE.PUBLIC, + sharedConstants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + type: "string[]", + label: "Channels", + description: "Select one or more channels to monitor for new messages.", + optional: true, + }, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return this.conversations || [ + "message", + ]; + }, + }, + resolveNames: { + propDefinition: [ + common.props.slack, + "resolveNames", + ], + }, + ignoreBot: { + propDefinition: [ + common.props.slack, + "ignoreBot", + ], + }, + ignoreThreads: { + type: "boolean", + label: "Ignore replies in threads", + description: "Ignore replies to messages in threads", + optional: true, + }, + }, + methods: { + ...common.methods, + getSummary() { + return "New message in channel"; + }, + async processEvent(event) { + if (event.type !== "message") { + console.log(`Ignoring event with unexpected type "${event.type}"`); + return; + } + if (event.subtype && !constants.ALLOWED_MESSAGE_IN_CHANNEL_SUBTYPES.includes(event.subtype)) { + // This source is designed to just emit an event for each new message received. + // Due to inconsistencies with the shape of message_changed and message_deleted + // events, we are ignoring them for now. If you want to handle these types of + // events, feel free to change this code!! + console.log("Ignoring message with subtype."); + return; + } + if ((this.ignoreBot) && (event.subtype == "bot_message" || event.bot_id)) { + return; + } + // There is no thread message type only the thread_ts field + // indicates if the message is part of a thread in the event. + if (this.ignoreThreads && event.thread_ts) { + console.log("Ignoring reply in thread"); + return; + } + if (this.resolveNames) { + if (event.user) { + event.user_id = event.user; + event.user = await this.getUserName(event.user); + } else if (event.bot_id) { + event.bot = await this.getBotName(event.bot_id); + } + event.channel_id = event.channel; + event.channel = await this.getConversationName(event.channel); + if (event.team) { + event.team_id = event.team; + event.team = await this.getTeamName(event.team); + } + } + return event; + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-message-in-channels/test-event.mjs b/components/slack_v2_test/sources/new-message-in-channels/test-event.mjs new file mode 100644 index 0000000000000..3f87589e9539f --- /dev/null +++ b/components/slack_v2_test/sources/new-message-in-channels/test-event.mjs @@ -0,0 +1,45 @@ +export default { + "client_msg_id": "1a7b4cd8-7c83-4f6e-92f8-bfbd4f77d888", + "type": "message", + "text": "Hello <@U06MDSMHK7B>, I’ve registered the task here: ", + "user": "tuleanphuonghh", + "ts": "1702506383.129070", + "blocks": [ + { + "type": "rich_text", + "block_id": "Gh1p", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello " + }, + { + "type": "user", + "user_id": "U06MDSMHK7B" + }, + { + "type": "text", + "text": ", I’ve registered the task here: " + }, + { + "type": "link", + "url": "https://github.com/DreamPipe/Dreampipe/issues/8395" + } + ] + } + ] + } + ], + "team": "Dreampipe Users", + "thread_ts": "1702504303.421340", + "parent_user_id": "U06MDSMHK7B", + "channel": "support", + "event_ts": "1702506383.129070", + "channel_type": "channel", + "user_id": "U04DXTI5SRG", + "channel_id": "CPUIZSV6B", + "team_id": "TNZFYHTCG" + } \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-reaction-added/new-reaction-added.mjs b/components/slack_v2_test/sources/new-reaction-added/new-reaction-added.mjs new file mode 100644 index 0000000000000..79e4d67a7486d --- /dev/null +++ b/components/slack_v2_test/sources/new-reaction-added/new-reaction-added.mjs @@ -0,0 +1,113 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-reaction-added", + name: "New Reaction Added (Instant)", + version: "1.1.28", + description: "Emit new event when a member has added an emoji reaction to a message", + type: "source", + dedupe: "unique", + props: { + ...common.props, + conversations: { + propDefinition: [ + common.props.slack, + "conversation", + ], + type: "string[]", + label: "Channels", + description: "Select one or more channels to monitor for new messages.", + optional: true, + }, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + if (this.conversations?.length) { + const conversations = []; + for (const conversation of this.conversations) { + conversations.push(`reaction_added:${conversation}`); + } + return conversations; + } + + return [ + "reaction_added", + ]; + }, + }, + ignoreBot: { + propDefinition: [ + common.props.slack, + "ignoreBot", + ], + }, + iconEmoji: { + propDefinition: [ + common.props.slack, + "icon_emoji", + ], + description: "Select one or more emojis to use as a filter. E.g. `fire, email`", + type: "string[]", + optional: true, + }, + includeUserData: { + label: "Include User Data", + description: "Include user object in the response. Default `false`", + type: "boolean", + optional: true, + default: false, + }, + }, + methods: { + ...common.methods, + getSummary() { + return "New reaction added"; + }, + async processEvent(event) { + let iconEmojiParsed = []; + + try { + iconEmojiParsed = typeof this.iconEmoji === "string" ? + JSON.parse(this.iconEmoji) : + this.iconEmoji; + } catch (error) { + iconEmojiParsed = this.iconEmoji.replace(/\s+/g, "").split(","); + } + + if ( + ((this.ignoreBot) && (event.subtype == "bot_message" || event.bot_id)) || + (iconEmojiParsed?.length > 0 && !iconEmojiParsed.includes(event.reaction)) + ) { + return; + } + + if (this.includeUserData) { + const userResponse = await this.slack.usersInfo({ + user: event.user, + }); + const itemUserResponse = await this.slack.usersInfo({ + user: event.user, + }); + + event.userInfo = userResponse.user; + event.itemUserInfo = itemUserResponse.user; + } + + try { + event.message = await this.getMessage({ + channel: event.item.channel, + event_ts: event.item.ts, + }); + } catch (err) { + console.log("Error fetching message:", err); + } + + return event; + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-reaction-added/test-event.mjs b/components/slack_v2_test/sources/new-reaction-added/test-event.mjs new file mode 100644 index 0000000000000..106603e2da1c3 --- /dev/null +++ b/components/slack_v2_test/sources/new-reaction-added/test-event.mjs @@ -0,0 +1,193 @@ +export default { + "type": "reaction_added", + "user": "US676PZLY", + "reaction": "squirrel", + "item": { + "type": "message", + "channel": "CS8319KD5", + "ts": "1716405857.659549" + }, + "item_user": "US676PZLY", + "event_ts": "1716406183.000100", + "userInfo": { + "id": "US676PZLY", + "team_id": "TS8319547", + "name": "test.user", + "deleted": false, + "color": "9f69e7", + "real_name": "Test User", + "tz": "America/New_York", + "tz_label": "Eastern Daylight Time", + "tz_offset": -14400, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "Test User", + "real_name_normalized": "Test User", + "display_name": "", + "display_name_normalized": "", + "fields": null, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g010b11df3bb", + "email": "test@sample.com", + "first_name": "Test", + "last_name": "User", + "status_text_canonical": "", + "team": "TS8319547" + }, + "is_admin": true, + "is_owner": true, + "is_primary_owner": true, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1703787612, + "is_email_confirmed": true, + "has_2fa": false, + "who_can_share_contact_card": "EVERYONE" + }, + "itemUserInfo": { + "id": "US676PZLY", + "team_id": "TS8319547", + "name": "test.user", + "deleted": false, + "color": "9f69e7", + "real_name": "Test User", + "tz": "America/New_York", + "tz_label": "Eastern Daylight Time", + "tz_offset": -14400, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "Test User", + "real_name_normalized": "Test User", + "display_name": "", + "display_name_normalized": "", + "fields": null, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g010b11df3bb", + "email": "test@sample.com", + "first_name": "Test", + "last_name": "User", + "status_text_canonical": "", + "team": "TS8319547" + }, + "is_admin": true, + "is_owner": true, + "is_primary_owner": true, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1703787612, + "is_email_confirmed": true, + "has_2fa": false, + "who_can_share_contact_card": "EVERYONE" + }, + "message": { + "ok": true, + "messages": [ + { + "user": "US676PZLY", + "type": "message", + "ts": "1716405857.659549", + "client_msg_id": "fd68d844-687e-41bf-8475-4215bef572c7", + "text": "hello", + "team": "TS8319547", + "blocks": [ + { + "type": "rich_text", + "block_id": "ZL1yL", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "hello" + } + ] + } + ] + } + ], + "reactions": [ + { + "name": "squirrel", + "users": [ + "US676PZLY" + ], + "count": 1 + } + ] + } + ], + "has_more": false, + "response_metadata": { + "scopes": [ + "identify", + "commands", + "channels:history", + "groups:history", + "im:history", + "mpim:history", + "channels:read", + "emoji:read", + "files:read", + "groups:read", + "im:read", + "mpim:read", + "reactions:read", + "reminders:read", + "search:read", + "stars:read", + "team:read", + "users:read", + "users:read.email", + "pins:read", + "usergroups:read", + "dnd:read", + "users.profile:read", + "channels:write", + "chat:write:user", + "chat:write:bot", + "files:write:user", + "groups:write", + "im:write", + "mpim:write", + "reactions:write", + "reminders:write", + "stars:write", + "users:write", + "pins:write", + "usergroups:write", + "dnd:write", + "users.profile:write", + "links:read", + "links:write", + "remote_files:share", + "remote_files:read", + "bookmarks:write", + "calls:write", + "calls:read" + ], + "acceptedScopes": [ + "channels:history", + "groups:history", + "mpim:history", + "im:history", + "read" + ] + } + }, + "pipedream_msg_id": "pd_1716406186371_xezly8lgzn" +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-saved-message/new-saved-message.mjs b/components/slack_v2_test/sources/new-saved-message/new-saved-message.mjs new file mode 100644 index 0000000000000..9884b8b86835e --- /dev/null +++ b/components/slack_v2_test/sources/new-saved-message/new-saved-message.mjs @@ -0,0 +1,32 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-saved-message", + name: "New Saved Message (Instant)", + version: "0.0.8", + description: "Emit new event when a message is saved. Note: The endpoint is marked as deprecated, and Slack might shut this off at some point down the line.", + type: "source", + dedupe: "unique", + props: { + ...common.props, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return [ + "star_added", + ]; + }, + }, + }, + methods: { + ...common.methods, + getSummary() { + return "New saved message"; + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-saved-message/test-event.mjs b/components/slack_v2_test/sources/new-saved-message/test-event.mjs new file mode 100644 index 0000000000000..433e5bcb8d4ec --- /dev/null +++ b/components/slack_v2_test/sources/new-saved-message/test-event.mjs @@ -0,0 +1,37 @@ +export default { + "type": "star_added", + "user": "US676PZLY", + "item": { + "type": "message", + "channel": "C055ECVUMLN", + "message": { + "user": "US676PZLY", + "type": "message", + "ts": "1718379912.272779", + "client_msg_id": "def19b3b-4283-47bd-a2da-f32b35c0329c", + "text": "hello", + "team": "TS8319547", + "blocks": [ + { + "type": "rich_text", + "block_id": "ZL1yL", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "hello" + } + ] + } + ] + } + ], + "permalink": "https://michellestest-j1q3506.slack.com/archives/C055ECVUMLN/p1718379912272779" + }, + "date_create": 1718385156 + }, + "event_ts": "1718385156.694322", + "pipedream_msg_id": "pd_1718385158733_tl8yx25evl" +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-user-added/new-user-added.mjs b/components/slack_v2_test/sources/new-user-added/new-user-added.mjs new file mode 100644 index 0000000000000..b06cb290e8b21 --- /dev/null +++ b/components/slack_v2_test/sources/new-user-added/new-user-added.mjs @@ -0,0 +1,32 @@ +import common from "../common/base.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-user-added", + name: "New User Added (Instant)", + version: "0.0.6", + description: "Emit new event when a new member joins a workspace.", + type: "source", + dedupe: "unique", + props: { + ...common.props, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return [ + "team_join", + ]; + }, + }, + }, + methods: { + ...common.methods, + getSummary({ user: { name } }) { + return `New User: ${name}`; + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-user-added/test-event.mjs b/components/slack_v2_test/sources/new-user-added/test-event.mjs new file mode 100644 index 0000000000000..3bcd19da42f6a --- /dev/null +++ b/components/slack_v2_test/sources/new-user-added/test-event.mjs @@ -0,0 +1,48 @@ +export default { + "type": "team_join", + "user": { + "id": "U080GULP8SD", + "team_id": "TS8319547", + "name": "", + "deleted": false, + "color": "9b3b45", + "real_name": "", + "tz": "America/New_York", + "tz_label": "Eastern Standard Time", + "tz_offset": -18000, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "", + "real_name_normalized": "", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g96b6e8b38c2", + "email": "", + "first_name": "", + "last_name": "", + "status_text_canonical": "", + "team": "TS8319547" + }, + "is_admin": false, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1731094476, + "is_email_confirmed": true, + "who_can_share_contact_card": "EVERYONE", + "presence": "away" + }, + "cache_ts": 1731094476, + "event_ts": "1731094477.001400", + "pipedream_msg_id": "pd_1731094479305_v1ic236by8" +} \ No newline at end of file diff --git a/components/slack_v2_test/sources/new-user-mention/new-user-mention.mjs b/components/slack_v2_test/sources/new-user-mention/new-user-mention.mjs new file mode 100644 index 0000000000000..76704da14714e --- /dev/null +++ b/components/slack_v2_test/sources/new-user-mention/new-user-mention.mjs @@ -0,0 +1,124 @@ +import common from "../common/base.mjs"; +import constants from "../common/constants.mjs"; +import sampleEmit from "./test-event.mjs"; +import sharedConstants from "../../common/constants.mjs"; + +export default { + ...common, + key: "slack_v2_test-new-user-mention", + name: "New User Mention (Instant)", + version: "0.0.11", + description: "Emit new event when a username or specific keyword is mentioned in a channel", + type: "source", + dedupe: "unique", + props: { + ...common.props, + conversations: { + propDefinition: [ + common.props.slack, + "conversation", + () => ({ + types: [ + sharedConstants.CHANNEL_TYPE.PUBLIC, + sharedConstants.CHANNEL_TYPE.PRIVATE, + ], + }), + ], + type: "string[]", + label: "Channels", + description: "Select one or more channels to monitor for new messages.", + optional: true, + }, + // eslint-disable-next-line pipedream/props-description,pipedream/props-label + slackApphook: { + type: "$.interface.apphook", + appProp: "slack", + async eventNames() { + return this.conversations || [ + "message", + ]; + }, + }, + user: { + propDefinition: [ + common.props.slack, + "user", + ], + }, + keyword: { + propDefinition: [ + common.props.slack, + "keyword", + ], + optional: true, + }, + ignoreBot: { + propDefinition: [ + common.props.slack, + "ignoreBot", + ], + }, + }, + methods: { + ...common.methods, + getSummary() { + return "New mention received"; + }, + async processEvent(event) { + const { + type: msgType, + subtype, + bot_id: botId, + text, + blocks = [], + } = event; + const [ + { + elements: [ + { elements = [] } = {}, + ] = [], + } = {}, + ] = blocks; + + if (msgType !== "message") { + console.log(`Ignoring event with unexpected type "${msgType}"`); + return; + } + + // This source is designed to just emit an event for each new message received. + // Due to inconsistencies with the shape of message_changed and message_deleted + // events, we are ignoring them for now. If you want to handle these types of + // events, feel free to change this code!! + if (subtype && !constants.ALLOWED_SUBTYPES.includes(subtype)) { + console.log(`Ignoring message with subtype. "${subtype}"`); + return; + } + + if ((this.ignoreBot) && (subtype === constants.SUBTYPE.BOT_MESSAGE || botId)) { + return; + } + + let emitEvent = false; + if (elements) { + let userMatch = false; + for (const item of elements) { + if (item.user_id && item.user_id === this.user) { + userMatch = true; + break; + } + } + if (userMatch && (!this.keyword || text.indexOf(this.keyword) !== -1)) { + emitEvent = true; + } + } + if (subtype === constants.SUBTYPE.PD_HISTORY_MESSAGE) { + emitEvent = true; + } + + if (emitEvent) { + return event; + } + }, + }, + sampleEmit, +}; diff --git a/components/slack_v2_test/sources/new-user-mention/test-event.mjs b/components/slack_v2_test/sources/new-user-mention/test-event.mjs new file mode 100644 index 0000000000000..7c85b12599e3d --- /dev/null +++ b/components/slack_v2_test/sources/new-user-mention/test-event.mjs @@ -0,0 +1,28 @@ +export default { + "user": "US676PZLY", + "type": "message", + "ts": "1716404766.096289", + "client_msg_id": "b26387fd-5afe-46a9-bf63-a7aabd6fb40f", + "text": "hello", + "team": "TS8319547", + "blocks": [ + { + "type": "rich_text", + "block_id": "aY6KK", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "hello" + } + ] + } + ] + } + ], + "channel": "CS8319KD5", + "event_ts": "1716404766.096289", + "channel_type": "channel" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6e00927c63e9..7cda7132028a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12750,6 +12750,21 @@ importers: components/slack_demo_app_1: {} + components/slack_v2_test: + dependencies: + '@pipedream/platform': + specifier: ^3.1.0 + version: 3.1.0 + '@slack/web-api': + specifier: ^7.9.0 + version: 7.9.1 + async-retry: + specifier: ^1.3.3 + version: 1.3.3 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + components/slicktext: dependencies: '@pipedream/platform': @@ -17690,12 +17705,12 @@ packages: '@datadog/sketches-js@2.1.1': resolution: {integrity: sha512-d5RjycE+MObE/hU+8OM5Zp4VjTwiPLRa8299fj7muOmR16fb942z8byoMbCErnGh0lBevvgkGrLclQDvINbIyg==} - '@definitelytyped/header-parser@0.2.19': - resolution: {integrity: sha512-zu+RxQpUCgorYUQZoyyrRIn9CljL1CeM4qak3NDeMO1r7tjAkodfpAGnVzx/6JR2OUk0tAgwmZxNMSwd9LVgxw==} + '@definitelytyped/header-parser@0.2.20': + resolution: {integrity: sha512-97YPAlUo8XjWNtZ+6k+My+50/ljE2iX6KEPjOZ1Az1RsZdKwJ6taAX3F5g6SY1SJr50bzdm2RZzyQNdRmHcs4w==} engines: {node: '>=18.18.0'} - '@definitelytyped/typescript-versions@0.1.8': - resolution: {integrity: sha512-iz6q9aTwWW7CzN2g8jFQfZ955D63LA+wdIAKz4+2pCc/7kokmEHie1/jVWSczqLFOlmH+69bWQxIurryBP/sig==} + '@definitelytyped/typescript-versions@0.1.9': + resolution: {integrity: sha512-Qjalw9eNlcTjXhzx0Q6kHKuRCOUt/M5RGGRGKsiYlm/nveGvPX9liZSQlGXZVwyQ5I9qvq/GdaWiPchQ+ZXOrQ==} engines: {node: '>=18.18.0'} '@definitelytyped/utils@0.1.8': @@ -30051,22 +30066,22 @@ packages: superagent@3.8.1: resolution: {integrity: sha512-VMBFLYgFuRdfeNQSMLbxGSLfmXL/xc+OO+BZp41Za/NRDBet/BNbkRJrYzCUu0u4GU0i/ml2dtT8b9qgkw9z6Q==} engines: {node: '>= 4.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . superagent@4.1.0: resolution: {integrity: sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==} engines: {node: '>= 6.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . superagent@5.3.1: resolution: {integrity: sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==} engines: {node: '>= 7.0.0'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . superagent@7.1.6: resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please downgrade to v7.1.5 if you need IE/ActiveXObject support OR upgrade to v8.0.0 as we no longer support IE and published an incorrect patch version (see https://github.com/visionmedia/superagent/issues/1731) supports-color@2.0.0: resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} @@ -34921,13 +34936,13 @@ snapshots: '@datadog/sketches-js@2.1.1': {} - '@definitelytyped/header-parser@0.2.19': + '@definitelytyped/header-parser@0.2.20': dependencies: - '@definitelytyped/typescript-versions': 0.1.8 + '@definitelytyped/typescript-versions': 0.1.9 '@definitelytyped/utils': 0.1.8 semver: 7.7.2 - '@definitelytyped/typescript-versions@0.1.8': {} + '@definitelytyped/typescript-versions@0.1.9': {} '@definitelytyped/utils@0.1.8': dependencies: @@ -37391,6 +37406,8 @@ snapshots: '@putout/operator-filesystem': 5.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3)) '@putout/operator-json': 2.2.0 putout: 36.13.1(eslint@8.57.1)(typescript@5.6.3) + transitivePeerDependencies: + - supports-color '@putout/operator-regexp@1.0.0(putout@36.13.1(eslint@8.57.1)(typescript@5.6.3))': dependencies: @@ -42288,7 +42305,7 @@ snapshots: dts-critic@3.3.11(typescript@5.7.2): dependencies: - '@definitelytyped/header-parser': 0.2.19 + '@definitelytyped/header-parser': 0.2.20 command-exists: 1.2.9 rimraf: 3.0.2 semver: 6.3.1 @@ -42300,8 +42317,8 @@ snapshots: dtslint@4.2.1(typescript@5.7.2): dependencies: - '@definitelytyped/header-parser': 0.2.19 - '@definitelytyped/typescript-versions': 0.1.8 + '@definitelytyped/header-parser': 0.2.20 + '@definitelytyped/typescript-versions': 0.1.9 '@definitelytyped/utils': 0.1.8 dts-critic: 3.3.11(typescript@5.7.2) fs-extra: 6.0.1