diff --git a/apps/docs/content/docs/en/tools/reddit.mdx b/apps/docs/content/docs/en/tools/reddit.mdx index cf466f5089c..bce451955c0 100644 --- a/apps/docs/content/docs/en/tools/reddit.mdx +++ b/apps/docs/content/docs/en/tools/reddit.mdx @@ -24,7 +24,7 @@ These operations let your agents access and analyze Reddit content as part of yo ## Usage Instructions -Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, and manage your Reddit account. +Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, manage messages, and access user and subreddit info. @@ -39,14 +39,15 @@ Fetch posts from a subreddit with different sorting options | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `subreddit` | string | Yes | The subreddit to fetch posts from \(e.g., "technology", "news"\) | -| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising"\). Default: "hot" | +| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising", "controversial"\). Default: "hot" | | `limit` | number | No | Maximum number of posts to return \(e.g., 25\). Default: 10, max: 100 | -| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "day"\) | +| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "all"\) | | `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) | | `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) | | `count` | number | No | A count of items already seen in the listing \(used for numbering\) | | `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) | | `sr_detail` | boolean | No | Expand subreddit details in the response | +| `g` | string | No | Geo filter for posts \(e.g., "GLOBAL", "US", "AR", etc.\) | #### Output @@ -55,6 +56,7 @@ Fetch posts from a subreddit with different sorting options | `subreddit` | string | Name of the subreddit where posts were fetched from | | `posts` | array | Array of posts with title, author, URL, score, comments count, and metadata | | ↳ `id` | string | Post ID | +| ↳ `name` | string | Thing fullname \(t3_xxxxx\) | | ↳ `title` | string | Post title | | ↳ `author` | string | Author username | | ↳ `url` | string | Post URL | @@ -66,6 +68,8 @@ Fetch posts from a subreddit with different sorting options | ↳ `selftext` | string | Text content for self posts | | ↳ `thumbnail` | string | Thumbnail URL | | ↳ `subreddit` | string | Subreddit name | +| `after` | string | Fullname of the last item for forward pagination | +| `before` | string | Fullname of the first item for backward pagination | ### `reddit_get_comments` @@ -83,12 +87,9 @@ Fetch comments from a specific Reddit post | `context` | number | No | Number of parent comments to include | | `showedits` | boolean | No | Show edit information for comments | | `showmore` | boolean | No | Include "load more comments" elements in the response | -| `showtitle` | boolean | No | Include submission title in the response | | `threaded` | boolean | No | Return comments in threaded/nested format | | `truncate` | number | No | Integer to truncate comment depth | -| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) | -| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) | -| `count` | number | No | A count of items already seen in the listing \(used for numbering\) | +| `comment` | string | No | ID36 of a comment to focus on \(returns that comment thread\) | #### Output @@ -96,6 +97,7 @@ Fetch comments from a specific Reddit post | --------- | ---- | ----------- | | `post` | object | Post information including ID, title, author, content, and metadata | | ↳ `id` | string | Post ID | +| ↳ `name` | string | Thing fullname \(t3_xxxxx\) | | ↳ `title` | string | Post title | | ↳ `author` | string | Post author | | ↳ `selftext` | string | Post text content | @@ -104,6 +106,7 @@ Fetch comments from a specific Reddit post | ↳ `permalink` | string | Reddit permalink | | `comments` | array | Nested comments with author, body, score, timestamps, and replies | | ↳ `id` | string | Comment ID | +| ↳ `name` | string | Thing fullname \(t1_xxxxx\) | | ↳ `author` | string | Comment author | | ↳ `body` | string | Comment text | | ↳ `score` | number | Comment score | @@ -135,6 +138,7 @@ Fetch controversial posts from a subreddit | `subreddit` | string | Name of the subreddit where posts were fetched from | | `posts` | array | Array of controversial posts with title, author, URL, score, comments count, and metadata | | ↳ `id` | string | Post ID | +| ↳ `name` | string | Thing fullname \(t3_xxxxx\) | | ↳ `title` | string | Post title | | ↳ `author` | string | Author username | | ↳ `url` | string | Post URL | @@ -146,6 +150,8 @@ Fetch controversial posts from a subreddit | ↳ `selftext` | string | Text content for self posts | | ↳ `thumbnail` | string | Thumbnail URL | | ↳ `subreddit` | string | Subreddit name | +| `after` | string | Fullname of the last item for forward pagination | +| `before` | string | Fullname of the first item for backward pagination | ### `reddit_search` @@ -165,6 +171,8 @@ Search for posts within a subreddit | `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) | | `count` | number | No | A count of items already seen in the listing \(used for numbering\) | | `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) | +| `type` | string | No | Type of search results: "link" \(posts\), "sr" \(subreddits\), or "user" \(users\). Default: "link" | +| `sr_detail` | boolean | No | Expand subreddit details in the response | #### Output @@ -173,6 +181,7 @@ Search for posts within a subreddit | `subreddit` | string | Name of the subreddit where search was performed | | `posts` | array | Array of search result posts with title, author, URL, score, comments count, and metadata | | ↳ `id` | string | Post ID | +| ↳ `name` | string | Thing fullname \(t3_xxxxx\) | | ↳ `title` | string | Post title | | ↳ `author` | string | Author username | | ↳ `url` | string | Post URL | @@ -184,6 +193,8 @@ Search for posts within a subreddit | ↳ `selftext` | string | Text content for self posts | | ↳ `thumbnail` | string | Thumbnail URL | | ↳ `subreddit` | string | Subreddit name | +| `after` | string | Fullname of the last item for forward pagination | +| `before` | string | Fullname of the first item for backward pagination | ### `reddit_submit_post` @@ -200,6 +211,9 @@ Submit a new post to a subreddit (text or link) | `nsfw` | boolean | No | Mark post as NSFW | | `spoiler` | boolean | No | Mark post as spoiler | | `send_replies` | boolean | No | Send reply notifications to inbox \(default: true\) | +| `flair_id` | string | No | Flair template UUID for the post \(max 36 characters\) | +| `flair_text` | string | No | Flair text to display on the post \(max 64 characters\) | +| `collection_id` | string | No | Collection UUID to add the post to | #### Output @@ -264,6 +278,21 @@ Save a Reddit post or comment to your saved items | `posts` | json | Posts data | | `post` | json | Single post data | | `comments` | json | Comments data | +| `success` | boolean | Operation success status | +| `message` | string | Result message | +| `data` | json | Response data | +| `after` | string | Pagination cursor \(next page\) | +| `before` | string | Pagination cursor \(previous page\) | +| `id` | string | Entity ID | +| `name` | string | Entity fullname | +| `messages` | json | Messages data | +| `display_name` | string | Subreddit display name | +| `subscribers` | number | Subscriber count | +| `description` | string | Description text | +| `link_karma` | number | Link karma | +| `comment_karma` | number | Comment karma | +| `total_karma` | number | Total karma | +| `icon_img` | string | Icon image URL | ### `reddit_reply` @@ -275,6 +304,7 @@ Add a comment reply to a Reddit post or comment | --------- | ---- | -------- | ----------- | | `parent_id` | string | Yes | Thing fullname to reply to \(e.g., "t3_abc123" for post, "t1_def456" for comment\) | | `text` | string | Yes | Comment text in markdown format \(e.g., "Great post! Here is my **reply**"\) | +| `return_rtjson` | boolean | No | Return response in Rich Text JSON format | #### Output @@ -345,4 +375,138 @@ Subscribe or unsubscribe from a subreddit | `success` | boolean | Whether the subscription action was successful | | `message` | string | Success or error message | +### `reddit_get_me` + +Get information about the authenticated Reddit user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `name` | string | Username | +| `created_utc` | number | Account creation time in UTC epoch seconds | +| `link_karma` | number | Total link karma | +| `comment_karma` | number | Total comment karma | +| `total_karma` | number | Combined total karma | +| `is_gold` | boolean | Whether user has Reddit Premium | +| `is_mod` | boolean | Whether user is a moderator | +| `has_verified_email` | boolean | Whether email is verified | +| `icon_img` | string | User avatar/icon URL | + +### `reddit_get_user` + +Get public profile information about any Reddit user by username + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `username` | string | Yes | Reddit username to look up \(e.g., "spez", "example_user"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `name` | string | Username | +| `created_utc` | number | Account creation time in UTC epoch seconds | +| `link_karma` | number | Total link karma | +| `comment_karma` | number | Total comment karma | +| `total_karma` | number | Combined total karma | +| `is_gold` | boolean | Whether user has Reddit Premium | +| `is_mod` | boolean | Whether user is a moderator | +| `has_verified_email` | boolean | Whether email is verified | +| `icon_img` | string | User avatar/icon URL | + +### `reddit_send_message` + +Send a private message to a Reddit user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `to` | string | Yes | Recipient username \(e.g., "example_user"\) or subreddit \(e.g., "/r/subreddit"\) | +| `subject` | string | Yes | Message subject \(max 100 characters\) | +| `text` | string | Yes | Message body in markdown format | +| `from_sr` | string | No | Subreddit name to send the message from \(requires moderator mail permission\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the message was sent successfully | +| `message` | string | Success or error message | + +### `reddit_get_messages` + +Retrieve private messages from your Reddit inbox + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `where` | string | No | Message folder to retrieve: "inbox" \(all\), "unread", "sent", "messages" \(direct messages only\), "comments" \(comment replies\), "selfreply" \(self-post replies\), or "mentions" \(username mentions\). Default: "inbox" | +| `limit` | number | No | Maximum number of messages to return \(e.g., 25\). Default: 25, max: 100 | +| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) | +| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) | +| `mark` | boolean | No | Whether to mark fetched messages as read | +| `count` | number | No | A count of items already seen in the listing \(used for numbering\) | +| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messages` | array | Array of messages with sender, recipient, subject, body, and metadata | +| ↳ `id` | string | Message ID | +| ↳ `name` | string | Thing fullname \(t4_xxxxx\) | +| ↳ `author` | string | Sender username | +| ↳ `dest` | string | Recipient username | +| ↳ `subject` | string | Message subject | +| ↳ `body` | string | Message body text | +| ↳ `created_utc` | number | Creation time in UTC epoch seconds | +| ↳ `new` | boolean | Whether the message is unread | +| ↳ `was_comment` | boolean | Whether the message is a comment reply | +| ↳ `context` | string | Context URL for comment replies | +| ↳ `distinguished` | string | Distinction: null/"moderator"/"admin" | +| `after` | string | Fullname of the last item for forward pagination | +| `before` | string | Fullname of the first item for backward pagination | + +### `reddit_get_subreddit_info` + +Get metadata and information about a subreddit + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `subreddit` | string | Yes | The subreddit to get info about \(e.g., "technology", "programming", "news"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Subreddit ID | +| `name` | string | Subreddit fullname \(t5_xxxxx\) | +| `display_name` | string | Subreddit name without prefix | +| `title` | string | Subreddit title | +| `description` | string | Full subreddit description \(markdown\) | +| `public_description` | string | Short public description | +| `subscribers` | number | Number of subscribers | +| `accounts_active` | number | Number of currently active users | +| `created_utc` | number | Creation time in UTC epoch seconds | +| `over18` | boolean | Whether the subreddit is NSFW | +| `lang` | string | Primary language of the subreddit | +| `subreddit_type` | string | Subreddit type: public, private, restricted, etc. | +| `url` | string | Subreddit URL path \(e.g., /r/technology/\) | +| `icon_img` | string | Subreddit icon URL | +| `banner_img` | string | Subreddit banner URL | + diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index 6f6feb28856..7ecfe09fe0e 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -1,6 +1,6 @@ --- title: Slack -description: Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack +description: Send, update, delete messages, manage views and modals, add or remove reactions, manage canvases, get channel info and user presence in Slack --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai]( ## Usage Instructions -Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel. +Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, open/update/push modal views, publish Home tab views, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel. @@ -923,4 +923,189 @@ Create a canvas pinned to a Slack channel as its resource hub | --------- | ---- | ----------- | | `canvas_id` | string | ID of the created channel canvas | +### `slack_open_view` + +Open a modal view in Slack using a trigger_id from an interaction payload. Used to display forms, confirmations, and other interactive modals. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., slash command, button click\) | +| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user | +| `view` | json | Yes | A view payload object defining the modal. Must include type \("modal"\), title, and blocks array | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `view` | object | The opened modal view object | +| ↳ `id` | string | Unique view identifier | +| ↳ `team_id` | string | Workspace/team ID | +| ↳ `type` | string | View type \(e.g., "modal"\) | +| ↳ `title` | json | Plain text title object with type and text fields | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Title text content | +| ↳ `submit` | json | Plain text submit button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Submit button text | +| ↳ `close` | json | Plain text close button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Close button text | +| ↳ `blocks` | array | Block Kit blocks in the view | +| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) | +| ↳ `block_id` | string | Unique block identifier | +| ↳ `private_metadata` | string | Private metadata string passed with the view | +| ↳ `callback_id` | string | Custom identifier for the view | +| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) | +| ↳ `state` | json | Current state of the view with input values | +| ↳ `hash` | string | View version hash for updates | +| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed | +| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed | +| ↳ `root_view_id` | string | ID of the root view in the view stack | +| ↳ `previous_view_id` | string | ID of the previous view in the view stack | +| ↳ `app_id` | string | Application identifier | +| ↳ `bot_id` | string | Bot identifier | + +### `slack_update_view` + +Update an existing modal view in Slack. Identify the view by view_id or external_id, and provide the updated view payload. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `viewId` | string | No | Unique identifier of the view to update. Either viewId or externalId is required | +| `externalId` | string | No | Developer-set unique identifier of the view to update \(max 255 chars\). Either viewId or externalId is required | +| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response | +| `view` | json | Yes | A view payload object defining the updated modal. Must include type \("modal"\), title, and blocks array. Use identical block_id and action_id values to preserve input data | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `view` | object | The updated modal view object | +| ↳ `id` | string | Unique view identifier | +| ↳ `team_id` | string | Workspace/team ID | +| ↳ `type` | string | View type \(e.g., "modal"\) | +| ↳ `title` | json | Plain text title object with type and text fields | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Title text content | +| ↳ `submit` | json | Plain text submit button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Submit button text | +| ↳ `close` | json | Plain text close button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Close button text | +| ↳ `blocks` | array | Block Kit blocks in the view | +| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) | +| ↳ `block_id` | string | Unique block identifier | +| ↳ `private_metadata` | string | Private metadata string passed with the view | +| ↳ `callback_id` | string | Custom identifier for the view | +| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) | +| ↳ `state` | json | Current state of the view with input values | +| ↳ `hash` | string | View version hash for updates | +| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed | +| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed | +| ↳ `root_view_id` | string | ID of the root view in the view stack | +| ↳ `previous_view_id` | string | ID of the previous view in the view stack | +| ↳ `app_id` | string | Application identifier | +| ↳ `bot_id` | string | Bot identifier | + +### `slack_push_view` + +Push a new view onto an existing modal stack in Slack. Limited to 2 additional views after the initial modal is opened. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., button click within an existing modal\) | +| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user | +| `view` | json | Yes | A view payload object defining the modal to push. Must include type \("modal"\), title, and blocks array | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `view` | object | The pushed modal view object | +| ↳ `id` | string | Unique view identifier | +| ↳ `team_id` | string | Workspace/team ID | +| ↳ `type` | string | View type \(e.g., "modal"\) | +| ↳ `title` | json | Plain text title object with type and text fields | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Title text content | +| ↳ `submit` | json | Plain text submit button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Submit button text | +| ↳ `close` | json | Plain text close button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Close button text | +| ↳ `blocks` | array | Block Kit blocks in the view | +| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) | +| ↳ `block_id` | string | Unique block identifier | +| ↳ `private_metadata` | string | Private metadata string passed with the view | +| ↳ `callback_id` | string | Custom identifier for the view | +| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) | +| ↳ `state` | json | Current state of the view with input values | +| ↳ `hash` | string | View version hash for updates | +| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed | +| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed | +| ↳ `root_view_id` | string | ID of the root view in the view stack | +| ↳ `previous_view_id` | string | ID of the previous view in the view stack | +| ↳ `app_id` | string | Application identifier | +| ↳ `bot_id` | string | Bot identifier | + +### `slack_publish_view` + +Publish a static view to a user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `userId` | string | Yes | The user ID to publish the Home tab view to \(e.g., U0BPQUNTA\) | +| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response | +| `view` | json | Yes | A view payload object defining the Home tab. Must include type \("home"\) and blocks array | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `view` | object | The published Home tab view object | +| ↳ `id` | string | Unique view identifier | +| ↳ `team_id` | string | Workspace/team ID | +| ↳ `type` | string | View type \(e.g., "modal"\) | +| ↳ `title` | json | Plain text title object with type and text fields | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Title text content | +| ↳ `submit` | json | Plain text submit button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Submit button text | +| ↳ `close` | json | Plain text close button object | +| ↳ `type` | string | Text object type \(plain_text\) | +| ↳ `text` | string | Close button text | +| ↳ `blocks` | array | Block Kit blocks in the view | +| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) | +| ↳ `block_id` | string | Unique block identifier | +| ↳ `private_metadata` | string | Private metadata string passed with the view | +| ↳ `callback_id` | string | Custom identifier for the view | +| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) | +| ↳ `state` | json | Current state of the view with input values | +| ↳ `hash` | string | View version hash for updates | +| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed | +| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed | +| ↳ `root_view_id` | string | ID of the root view in the view stack | +| ↳ `previous_view_id` | string | ID of the previous view in the view stack | +| ↳ `app_id` | string | Application identifier | +| ↳ `bot_id` | string | Bot identifier | + diff --git a/apps/sim/blocks/blocks/reddit.ts b/apps/sim/blocks/blocks/reddit.ts index 828d4e338eb..801e35b859e 100644 --- a/apps/sim/blocks/blocks/reddit.ts +++ b/apps/sim/blocks/blocks/reddit.ts @@ -9,13 +9,12 @@ export const RedditBlock: BlockConfig = { description: 'Access Reddit data and content', authMode: AuthMode.OAuth, longDescription: - 'Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, and manage your Reddit account.', + 'Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, manage messages, and access user and subreddit info.', docsLink: 'https://docs.sim.ai/tools/reddit', category: 'tools', bgColor: '#FF5700', icon: RedditIcon, subBlocks: [ - // Operation selection { id: 'operation', title: 'Operation', @@ -33,6 +32,11 @@ export const RedditBlock: BlockConfig = { { label: 'Edit', id: 'edit' }, { label: 'Delete', id: 'delete' }, { label: 'Subscribe', id: 'subscribe' }, + { label: 'Get My Profile', id: 'get_me' }, + { label: 'Get User Profile', id: 'get_user' }, + { label: 'Send Message', id: 'send_message' }, + { label: 'Get Messages', id: 'get_messages' }, + { label: 'Get Subreddit Info', id: 'get_subreddit_info' }, ], value: () => 'get_posts', }, @@ -76,7 +80,7 @@ export const RedditBlock: BlockConfig = { required: true, }, - // Common fields - appear for all actions + // ── Get Posts ────────────────────────────────────────────────────── { id: 'subreddit', title: 'Subreddit', @@ -84,12 +88,10 @@ export const RedditBlock: BlockConfig = { placeholder: 'Enter subreddit name (without r/)', condition: { field: 'operation', - value: ['get_posts', 'get_comments', 'get_controversial', 'search'], + value: ['get_posts', 'get_comments', 'get_controversial', 'search', 'get_subreddit_info'], }, required: true, }, - - // Get Posts specific fields { id: 'sort', title: 'Sort By', @@ -99,18 +101,17 @@ export const RedditBlock: BlockConfig = { { label: 'New', id: 'new' }, { label: 'Top', id: 'top' }, { label: 'Rising', id: 'rising' }, + { label: 'Controversial', id: 'controversial' }, ], - condition: { - field: 'operation', - value: 'get_posts', - }, + condition: { field: 'operation', value: 'get_posts' }, required: true, }, { id: 'time', - title: 'Time Filter (for Top sort)', + title: 'Time Filter', type: 'dropdown', options: [ + { label: 'Hour', id: 'hour' }, { label: 'Day', id: 'day' }, { label: 'Week', id: 'week' }, { label: 'Month', id: 'month' }, @@ -120,10 +121,7 @@ export const RedditBlock: BlockConfig = { condition: { field: 'operation', value: 'get_posts', - and: { - field: 'sort', - value: 'top', - }, + and: { field: 'sort', value: ['top', 'controversial'] }, }, }, { @@ -131,22 +129,38 @@ export const RedditBlock: BlockConfig = { title: 'Max Posts', type: 'short-input', placeholder: '10', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'after', + title: 'After', + type: 'short-input', + placeholder: 'Fullname for forward pagination (e.g., t3_xxxxx)', condition: { field: 'operation', - value: 'get_posts', + value: ['get_posts', 'get_controversial', 'search', 'get_messages'], }, + mode: 'advanced', }, - - // Get Comments specific fields { - id: 'postId', - title: 'Post ID', + id: 'before', + title: 'Before', type: 'short-input', - placeholder: 'Enter post ID', + placeholder: 'Fullname for backward pagination (e.g., t3_xxxxx)', condition: { field: 'operation', - value: 'get_comments', + value: ['get_posts', 'get_controversial', 'search', 'get_messages'], }, + mode: 'advanced', + }, + + // ── Get Comments ────────────────────────────────────────────────── + { + id: 'postId', + title: 'Post ID', + type: 'short-input', + placeholder: 'Enter post ID (e.g., abc123)', + condition: { field: 'operation', value: 'get_comments' }, required: true, }, { @@ -154,7 +168,7 @@ export const RedditBlock: BlockConfig = { title: 'Sort Comments By', type: 'dropdown', options: [ - { label: 'Confidence', id: 'confidence' }, + { label: 'Best', id: 'confidence' }, { label: 'Top', id: 'top' }, { label: 'New', id: 'new' }, { label: 'Controversial', id: 'controversial' }, @@ -162,23 +176,33 @@ export const RedditBlock: BlockConfig = { { label: 'Random', id: 'random' }, { label: 'Q&A', id: 'qa' }, ], - condition: { - field: 'operation', - value: 'get_comments', - }, + condition: { field: 'operation', value: 'get_comments' }, }, { id: 'commentLimit', title: 'Number of Comments', type: 'short-input', placeholder: '50', - condition: { - field: 'operation', - value: 'get_comments', - }, + condition: { field: 'operation', value: 'get_comments' }, + }, + { + id: 'commentDepth', + title: 'Max Reply Depth', + type: 'short-input', + placeholder: 'Max depth of nested replies', + condition: { field: 'operation', value: 'get_comments' }, + mode: 'advanced', + }, + { + id: 'commentFocus', + title: 'Focus Comment ID', + type: 'short-input', + placeholder: 'ID36 of a specific comment to focus on', + condition: { field: 'operation', value: 'get_comments' }, + mode: 'advanced', }, - // Get Controversial specific fields + // ── Get Controversial ───────────────────────────────────────────── { id: 'controversialTime', title: 'Time Filter', @@ -191,33 +215,44 @@ export const RedditBlock: BlockConfig = { { label: 'Year', id: 'year' }, { label: 'All Time', id: 'all' }, ], - condition: { - field: 'operation', - value: 'get_controversial', - }, + condition: { field: 'operation', value: 'get_controversial' }, }, { id: 'controversialLimit', title: 'Max Posts', type: 'short-input', placeholder: '10', - condition: { - field: 'operation', - value: 'get_controversial', - }, + condition: { field: 'operation', value: 'get_controversial' }, }, - // Search specific fields + // ── Search ──────────────────────────────────────────────────────── { id: 'searchQuery', title: 'Search Query', type: 'short-input', placeholder: 'Enter search query', - condition: { - field: 'operation', - value: 'search', - }, + condition: { field: 'operation', value: 'search' }, required: true, + wandConfig: { + enabled: true, + prompt: `Generate a Reddit search query based on the user's description. + +Reddit search supports: +- Simple text: "machine learning" +- Field searches: title:question, author:username, selftext:content, subreddit:name, url:example.com, site:example.com, flair:discussion +- Boolean operators: AND, OR, NOT (must be uppercase) +- Grouping with parentheses: (cats OR dogs) AND cute +- Exact phrases with quotes: "exact phrase" + +Examples: +- "posts about AI from last month" -> artificial intelligence +- "questions about Python" -> title:question python +- "posts linking to github" -> site:github.com +- "posts by user spez" -> author:spez + +Return ONLY the search query - no explanations, no extra text.`, + placeholder: 'Describe what you want to search for...', + }, }, { id: 'searchSort', @@ -230,10 +265,7 @@ export const RedditBlock: BlockConfig = { { label: 'New', id: 'new' }, { label: 'Comments', id: 'comments' }, ], - condition: { - field: 'operation', - value: 'search', - }, + condition: { field: 'operation', value: 'search' }, }, { id: 'searchTime', @@ -247,43 +279,30 @@ export const RedditBlock: BlockConfig = { { label: 'Year', id: 'year' }, { label: 'All Time', id: 'all' }, ], - condition: { - field: 'operation', - value: 'search', - }, + condition: { field: 'operation', value: 'search' }, }, { id: 'searchLimit', title: 'Max Results', type: 'short-input', placeholder: '10', - condition: { - field: 'operation', - value: 'search', - }, + condition: { field: 'operation', value: 'search' }, }, - - // Submit Post specific fields + // ── Submit Post ─────────────────────────────────────────────────── { id: 'submitSubreddit', title: 'Subreddit', type: 'short-input', placeholder: 'Enter subreddit name (without r/)', - condition: { - field: 'operation', - value: 'submit_post', - }, + condition: { field: 'operation', value: 'submit_post' }, required: true, }, { id: 'title', title: 'Post Title', type: 'short-input', - placeholder: 'Enter post title', - condition: { - field: 'operation', - value: 'submit_post', - }, + placeholder: 'Enter post title (max 300 characters)', + condition: { field: 'operation', value: 'submit_post' }, required: true, }, { @@ -294,10 +313,7 @@ export const RedditBlock: BlockConfig = { { label: 'Text Post', id: 'text' }, { label: 'Link Post', id: 'link' }, ], - condition: { - field: 'operation', - value: 'submit_post', - }, + condition: { field: 'operation', value: 'submit_post' }, value: () => 'text', required: true, }, @@ -309,10 +325,27 @@ export const RedditBlock: BlockConfig = { condition: { field: 'operation', value: 'submit_post', - and: { - field: 'postType', - value: 'text', - }, + and: { field: 'postType', value: 'text' }, + }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `Generate Reddit post content in markdown format based on the user's description. + +Reddit markdown supports: +- **bold**, *italic*, ~~strikethrough~~ +- [links](url), ![images](url) +- > blockquotes +- - bullet lists, 1. numbered lists +- \`inline code\`, code blocks with triple backticks +- Headers with # (use sparingly) +- Horizontal rules with --- +- Tables with | pipes | +- Superscript with ^ + +Write engaging, well-formatted content appropriate for the subreddit context. +Return ONLY the markdown content - no meta-commentary.`, + placeholder: 'Describe what your post should say...', }, }, { @@ -323,10 +356,7 @@ export const RedditBlock: BlockConfig = { condition: { field: 'operation', value: 'submit_post', - and: { - field: 'postType', - value: 'link', - }, + and: { field: 'postType', value: 'link' }, }, }, { @@ -337,10 +367,7 @@ export const RedditBlock: BlockConfig = { { label: 'No', id: 'false' }, { label: 'Yes', id: 'true' }, ], - condition: { - field: 'operation', - value: 'submit_post', - }, + condition: { field: 'operation', value: 'submit_post' }, value: () => 'false', }, { @@ -351,23 +378,45 @@ export const RedditBlock: BlockConfig = { { label: 'No', id: 'false' }, { label: 'Yes', id: 'true' }, ], - condition: { - field: 'operation', - value: 'submit_post', - }, + condition: { field: 'operation', value: 'submit_post' }, value: () => 'false', }, + { + id: 'flairId', + title: 'Flair ID', + type: 'short-input', + placeholder: 'Flair template ID (max 36 characters)', + condition: { field: 'operation', value: 'submit_post' }, + mode: 'advanced', + }, + { + id: 'flairText', + title: 'Flair Text', + type: 'short-input', + placeholder: 'Flair text to display (max 64 characters)', + condition: { field: 'operation', value: 'submit_post' }, + mode: 'advanced', + }, + { + id: 'sendReplies', + title: 'Send Reply Notifications', + type: 'dropdown', + options: [ + { label: 'Yes (default)', id: 'true' }, + { label: 'No', id: 'false' }, + ], + condition: { field: 'operation', value: 'submit_post' }, + mode: 'advanced', + value: () => 'true', + }, - // Vote specific fields + // ── Vote ────────────────────────────────────────────────────────── { id: 'voteId', title: 'Post/Comment ID', type: 'short-input', - placeholder: 'Enter thing ID (e.g., t3_xxxxx for post, t1_xxxxx for comment)', - condition: { - field: 'operation', - value: 'vote', - }, + placeholder: 'Thing fullname (e.g., t3_xxxxx for post, t1_xxxxx for comment)', + condition: { field: 'operation', value: 'vote' }, required: true, }, { @@ -379,47 +428,36 @@ export const RedditBlock: BlockConfig = { { label: 'Unvote', id: '0' }, { label: 'Downvote', id: '-1' }, ], - condition: { - field: 'operation', - value: 'vote', - }, + condition: { field: 'operation', value: 'vote' }, value: () => '1', required: true, }, - // Save/Unsave specific fields + // ── Save / Unsave ───────────────────────────────────────────────── { id: 'saveId', title: 'Post/Comment ID', type: 'short-input', - placeholder: 'Enter thing ID (e.g., t3_xxxxx for post, t1_xxxxx for comment)', - condition: { - field: 'operation', - value: ['save', 'unsave'], - }, + placeholder: 'Thing fullname (e.g., t3_xxxxx for post, t1_xxxxx for comment)', + condition: { field: 'operation', value: ['save', 'unsave'] }, required: true, }, { id: 'saveCategory', title: 'Category', type: 'short-input', - placeholder: 'Enter category name', - condition: { - field: 'operation', - value: 'save', - }, + placeholder: 'Category name (Reddit Premium feature)', + condition: { field: 'operation', value: 'save' }, + mode: 'advanced', }, - // Reply specific fields + // ── Reply ───────────────────────────────────────────────────────── { id: 'replyParentId', title: 'Parent Post/Comment ID', type: 'short-input', - placeholder: 'Enter thing ID to reply to (e.g., t3_xxxxx for post, t1_xxxxx for comment)', - condition: { - field: 'operation', - value: 'reply', - }, + placeholder: 'Thing fullname to reply to (e.g., t3_xxxxx or t1_xxxxx)', + condition: { field: 'operation', value: 'reply' }, required: true, }, { @@ -427,23 +465,33 @@ export const RedditBlock: BlockConfig = { title: 'Reply Text (Markdown)', type: 'long-input', placeholder: 'Enter reply text in markdown format', - condition: { - field: 'operation', - value: 'reply', - }, + condition: { field: 'operation', value: 'reply' }, required: true, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `Generate a Reddit comment reply in markdown format based on the user's description. + +Reddit markdown supports: +- **bold**, *italic*, ~~strikethrough~~ +- [links](url) +- > blockquotes (for quoting parent) +- - bullet lists, 1. numbered lists +- \`inline code\`, code blocks with triple backticks + +Write a natural, conversational reply. Match the tone to the context. +Return ONLY the markdown content - no meta-commentary.`, + placeholder: 'Describe what your reply should say...', + }, }, - // Edit specific fields + // ── Edit ────────────────────────────────────────────────────────── { id: 'editThingId', title: 'Post/Comment ID', type: 'short-input', - placeholder: 'Enter thing ID to edit (e.g., t3_xxxxx for post, t1_xxxxx for comment)', - condition: { - field: 'operation', - value: 'edit', - }, + placeholder: 'Thing fullname to edit (e.g., t3_xxxxx or t1_xxxxx)', + condition: { field: 'operation', value: 'edit' }, required: true, }, { @@ -451,36 +499,27 @@ export const RedditBlock: BlockConfig = { title: 'New Text (Markdown)', type: 'long-input', placeholder: 'Enter new text in markdown format', - condition: { - field: 'operation', - value: 'edit', - }, + condition: { field: 'operation', value: 'edit' }, required: true, }, - // Delete specific fields + // ── Delete ──────────────────────────────────────────────────────── { id: 'deleteId', title: 'Post/Comment ID', type: 'short-input', - placeholder: 'Enter thing ID to delete (e.g., t3_xxxxx for post, t1_xxxxx for comment)', - condition: { - field: 'operation', - value: 'delete', - }, + placeholder: 'Thing fullname to delete (e.g., t3_xxxxx or t1_xxxxx)', + condition: { field: 'operation', value: 'delete' }, required: true, }, - // Subscribe specific fields + // ── Subscribe ───────────────────────────────────────────────────── { id: 'subscribeSubreddit', title: 'Subreddit', type: 'short-input', placeholder: 'Enter subreddit name (without r/)', - condition: { - field: 'operation', - value: 'subscribe', - }, + condition: { field: 'operation', value: 'subscribe' }, required: true, }, { @@ -491,13 +530,98 @@ export const RedditBlock: BlockConfig = { { label: 'Subscribe', id: 'sub' }, { label: 'Unsubscribe', id: 'unsub' }, ], - condition: { - field: 'operation', - value: 'subscribe', - }, + condition: { field: 'operation', value: 'subscribe' }, value: () => 'sub', required: true, }, + + // ── Get User Profile ────────────────────────────────────────────── + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Reddit username (e.g., spez)', + condition: { field: 'operation', value: 'get_user' }, + required: true, + }, + + // ── Send Message ────────────────────────────────────────────────── + { + id: 'messageTo', + title: 'Recipient', + type: 'short-input', + placeholder: 'Username or /r/subreddit', + condition: { field: 'operation', value: 'send_message' }, + required: true, + }, + { + id: 'messageSubject', + title: 'Subject', + type: 'short-input', + placeholder: 'Message subject (max 100 characters)', + condition: { field: 'operation', value: 'send_message' }, + required: true, + }, + { + id: 'messageText', + title: 'Message Body (Markdown)', + type: 'long-input', + placeholder: 'Enter message in markdown format', + condition: { field: 'operation', value: 'send_message' }, + required: true, + wandConfig: { + enabled: true, + prompt: `Generate a Reddit private message in markdown format based on the user's description. + +Write a clear, polite message. Reddit markdown supports **bold**, *italic*, [links](url), > quotes, lists, and code blocks. +Return ONLY the message content - no meta-commentary.`, + placeholder: 'Describe what your message should say...', + }, + }, + { + id: 'messageFromSr', + title: 'Send From Subreddit', + type: 'short-input', + placeholder: 'Subreddit name (requires mod mail permission)', + condition: { field: 'operation', value: 'send_message' }, + mode: 'advanced', + }, + + // ── Get Messages ────────────────────────────────────────────────── + { + id: 'messageWhere', + title: 'Message Folder', + type: 'dropdown', + options: [ + { label: 'Inbox (all)', id: 'inbox' }, + { label: 'Unread', id: 'unread' }, + { label: 'Sent', id: 'sent' }, + { label: 'Direct Messages', id: 'messages' }, + { label: 'Comment Replies', id: 'comments' }, + { label: 'Self-Post Replies', id: 'selfreply' }, + { label: 'Username Mentions', id: 'mentions' }, + ], + condition: { field: 'operation', value: 'get_messages' }, + value: () => 'inbox', + }, + { + id: 'messageLimit', + title: 'Max Messages', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'get_messages' }, + }, + { + id: 'messageMark', + title: 'Mark as Read', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + condition: { field: 'operation', value: 'get_messages' }, + mode: 'advanced', + }, ], tools: { access: [ @@ -513,163 +637,205 @@ export const RedditBlock: BlockConfig = { 'reddit_edit', 'reddit_delete', 'reddit_subscribe', + 'reddit_get_me', + 'reddit_get_user', + 'reddit_send_message', + 'reddit_get_messages', + 'reddit_get_subreddit_info', ], config: { tool: (inputs) => { const operation = inputs.operation || 'get_posts' - - if (operation === 'get_comments') { - return 'reddit_get_comments' + const toolMap: Record = { + get_posts: 'reddit_get_posts', + get_comments: 'reddit_get_comments', + get_controversial: 'reddit_get_controversial', + search: 'reddit_search', + submit_post: 'reddit_submit_post', + vote: 'reddit_vote', + save: 'reddit_save', + unsave: 'reddit_unsave', + reply: 'reddit_reply', + edit: 'reddit_edit', + delete: 'reddit_delete', + subscribe: 'reddit_subscribe', + get_me: 'reddit_get_me', + get_user: 'reddit_get_user', + send_message: 'reddit_send_message', + get_messages: 'reddit_get_messages', + get_subreddit_info: 'reddit_get_subreddit_info', } - - if (operation === 'get_controversial') { - return 'reddit_get_controversial' - } - - if (operation === 'search') { - return 'reddit_search' - } - - if (operation === 'submit_post') { - return 'reddit_submit_post' - } - - if (operation === 'vote') { - return 'reddit_vote' - } - - if (operation === 'save') { - return 'reddit_save' - } - - if (operation === 'unsave') { - return 'reddit_unsave' - } - - if (operation === 'reply') { - return 'reddit_reply' - } - - if (operation === 'edit') { - return 'reddit_edit' - } - - if (operation === 'delete') { - return 'reddit_delete' - } - - if (operation === 'subscribe') { - return 'reddit_subscribe' - } - - return 'reddit_get_posts' + return toolMap[operation] || 'reddit_get_posts' }, params: (inputs) => { const operation = inputs.operation || 'get_posts' - const { oauthCredential, ...rest } = inputs + const { oauthCredential } = inputs + + if (operation === 'get_posts') { + return { + subreddit: inputs.subreddit, + sort: inputs.sort, + time: + inputs.sort === 'top' || inputs.sort === 'controversial' ? inputs.time : undefined, + limit: inputs.limit ? Number.parseInt(inputs.limit) : undefined, + after: inputs.after || undefined, + before: inputs.before || undefined, + oauthCredential, + } + } if (operation === 'get_comments') { return { - postId: rest.postId, - subreddit: rest.subreddit, - sort: rest.commentSort, - limit: rest.commentLimit ? Number.parseInt(rest.commentLimit) : undefined, - oauthCredential: oauthCredential, + postId: inputs.postId, + subreddit: inputs.subreddit, + sort: inputs.commentSort, + limit: inputs.commentLimit ? Number.parseInt(inputs.commentLimit) : undefined, + depth: inputs.commentDepth ? Number.parseInt(inputs.commentDepth) : undefined, + comment: inputs.commentFocus || undefined, + oauthCredential, } } if (operation === 'get_controversial') { return { - subreddit: rest.subreddit, - time: rest.controversialTime, - limit: rest.controversialLimit ? Number.parseInt(rest.controversialLimit) : undefined, - oauthCredential: oauthCredential, + subreddit: inputs.subreddit, + time: inputs.controversialTime, + limit: inputs.controversialLimit + ? Number.parseInt(inputs.controversialLimit) + : undefined, + after: inputs.after || undefined, + before: inputs.before || undefined, + oauthCredential, } } if (operation === 'search') { return { - subreddit: rest.subreddit, - query: rest.searchQuery, - sort: rest.searchSort, - time: rest.searchTime, - limit: rest.searchLimit ? Number.parseInt(rest.searchLimit) : undefined, - oauthCredential: oauthCredential, + subreddit: inputs.subreddit, + query: inputs.searchQuery, + sort: inputs.searchSort, + time: inputs.searchTime, + limit: inputs.searchLimit ? Number.parseInt(inputs.searchLimit) : undefined, + after: inputs.after || undefined, + before: inputs.before || undefined, + oauthCredential, } } if (operation === 'submit_post') { return { - subreddit: rest.submitSubreddit, - title: rest.title, - text: rest.postType === 'text' ? rest.text : undefined, - url: rest.postType === 'link' ? rest.url : undefined, - nsfw: rest.nsfw === 'true', - spoiler: rest.spoiler === 'true', - oauthCredential: oauthCredential, + subreddit: inputs.submitSubreddit, + title: inputs.title, + text: inputs.postType === 'text' ? inputs.text : undefined, + url: inputs.postType === 'link' ? inputs.url : undefined, + nsfw: inputs.nsfw === 'true', + spoiler: inputs.spoiler === 'true', + send_replies: + inputs.sendReplies !== undefined ? inputs.sendReplies === 'true' : undefined, + flair_id: inputs.flairId || undefined, + flair_text: inputs.flairText || undefined, + oauthCredential, } } if (operation === 'vote') { return { - id: rest.voteId, - dir: Number.parseInt(rest.voteDirection), - oauthCredential: oauthCredential, + id: inputs.voteId, + dir: Number.parseInt(inputs.voteDirection), + oauthCredential, } } if (operation === 'save') { return { - id: rest.saveId, - category: rest.saveCategory, - oauthCredential: oauthCredential, + id: inputs.saveId, + category: inputs.saveCategory || undefined, + oauthCredential, } } if (operation === 'unsave') { return { - id: rest.saveId, - oauthCredential: oauthCredential, + id: inputs.saveId, + oauthCredential, } } if (operation === 'reply') { return { - parent_id: rest.replyParentId, - text: rest.replyText, - oauthCredential: oauthCredential, + parent_id: inputs.replyParentId, + text: inputs.replyText, + oauthCredential, } } if (operation === 'edit') { return { - thing_id: rest.editThingId, - text: rest.editText, - oauthCredential: oauthCredential, + thing_id: inputs.editThingId, + text: inputs.editText, + oauthCredential, } } if (operation === 'delete') { return { - id: rest.deleteId, - oauthCredential: oauthCredential, + id: inputs.deleteId, + oauthCredential, } } if (operation === 'subscribe') { return { - subreddit: rest.subscribeSubreddit, - action: rest.subscribeAction, - oauthCredential: oauthCredential, + subreddit: inputs.subscribeSubreddit, + action: inputs.subscribeAction, + oauthCredential, + } + } + + if (operation === 'get_me') { + return { oauthCredential } + } + + if (operation === 'get_user') { + return { + username: inputs.username, + oauthCredential, + } + } + + if (operation === 'send_message') { + return { + to: inputs.messageTo, + subject: inputs.messageSubject, + text: inputs.messageText, + from_sr: inputs.messageFromSr || undefined, + oauthCredential, + } + } + + if (operation === 'get_messages') { + return { + where: inputs.messageWhere, + limit: inputs.messageLimit ? Number.parseInt(inputs.messageLimit) : undefined, + mark: inputs.messageMark !== undefined ? inputs.messageMark === 'true' : undefined, + after: inputs.after || undefined, + before: inputs.before || undefined, + oauthCredential, + } + } + + if (operation === 'get_subreddit_info') { + return { + subreddit: inputs.subreddit, + oauthCredential, } } return { - subreddit: rest.subreddit, - sort: rest.sort, - limit: rest.limit ? Number.parseInt(rest.limit) : undefined, - time: rest.sort === 'top' ? rest.time : undefined, - oauthCredential: oauthCredential, + subreddit: inputs.subreddit, + sort: inputs.sort, + limit: inputs.limit ? Number.parseInt(inputs.limit) : undefined, + oauthCredential, } }, }, @@ -681,9 +847,13 @@ export const RedditBlock: BlockConfig = { sort: { type: 'string', description: 'Sort order' }, time: { type: 'string', description: 'Time filter' }, limit: { type: 'number', description: 'Maximum posts' }, + after: { type: 'string', description: 'Pagination cursor (after)' }, + before: { type: 'string', description: 'Pagination cursor (before)' }, postId: { type: 'string', description: 'Post identifier' }, commentSort: { type: 'string', description: 'Comment sort order' }, commentLimit: { type: 'number', description: 'Maximum comments' }, + commentDepth: { type: 'number', description: 'Maximum reply depth' }, + commentFocus: { type: 'string', description: 'Focus on specific comment' }, controversialTime: { type: 'string', description: 'Time filter for controversial posts' }, controversialLimit: { type: 'number', description: 'Maximum controversial posts' }, searchQuery: { type: 'string', description: 'Search query text' }, @@ -697,6 +867,9 @@ export const RedditBlock: BlockConfig = { url: { type: 'string', description: 'URL for link posts' }, nsfw: { type: 'boolean', description: 'Mark post as NSFW' }, spoiler: { type: 'boolean', description: 'Mark post as spoiler' }, + sendReplies: { type: 'boolean', description: 'Send reply notifications' }, + flairId: { type: 'string', description: 'Flair template ID' }, + flairText: { type: 'string', description: 'Flair display text' }, voteId: { type: 'string', description: 'Post or comment ID to vote on' }, voteDirection: { type: 'number', @@ -711,11 +884,34 @@ export const RedditBlock: BlockConfig = { deleteId: { type: 'string', description: 'Post or comment ID to delete' }, subscribeSubreddit: { type: 'string', description: 'Subreddit to subscribe/unsubscribe' }, subscribeAction: { type: 'string', description: 'Subscribe action (sub or unsub)' }, + username: { type: 'string', description: 'Reddit username to look up' }, + messageTo: { type: 'string', description: 'Message recipient' }, + messageSubject: { type: 'string', description: 'Message subject' }, + messageText: { type: 'string', description: 'Message body in markdown' }, + messageFromSr: { type: 'string', description: 'Send from subreddit (mod mail)' }, + messageWhere: { type: 'string', description: 'Message folder' }, + messageLimit: { type: 'number', description: 'Maximum messages' }, + messageMark: { type: 'boolean', description: 'Mark messages as read' }, }, outputs: { subreddit: { type: 'string', description: 'Subreddit name' }, posts: { type: 'json', description: 'Posts data' }, post: { type: 'json', description: 'Single post data' }, comments: { type: 'json', description: 'Comments data' }, + success: { type: 'boolean', description: 'Operation success status' }, + message: { type: 'string', description: 'Result message' }, + data: { type: 'json', description: 'Response data' }, + after: { type: 'string', description: 'Pagination cursor (next page)' }, + before: { type: 'string', description: 'Pagination cursor (previous page)' }, + id: { type: 'string', description: 'Entity ID' }, + name: { type: 'string', description: 'Entity fullname' }, + messages: { type: 'json', description: 'Messages data' }, + display_name: { type: 'string', description: 'Subreddit display name' }, + subscribers: { type: 'number', description: 'Subscriber count' }, + description: { type: 'string', description: 'Description text' }, + link_karma: { type: 'number', description: 'Link karma' }, + comment_karma: { type: 'number', description: 'Comment karma' }, + total_karma: { type: 'number', description: 'Total karma' }, + icon_img: { type: 'string', description: 'Icon image URL' }, }, } diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 3b30c75cde7..23ba4ccfdd0 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', description: - 'Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack', + 'Send, update, delete messages, manage views and modals, add or remove reactions, manage canvases, get channel info and user presence in Slack', authMode: AuthMode.OAuth, longDescription: - 'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.', + 'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, open/update/push modal views, publish Home tab views, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.', docsLink: 'https://docs.sim.ai/tools/slack', category: 'tools', bgColor: '#611f69', @@ -43,6 +43,10 @@ export const SlackBlock: BlockConfig = { { label: 'Get User Presence', id: 'get_user_presence' }, { label: 'Edit Canvas', id: 'edit_canvas' }, { label: 'Create Channel Canvas', id: 'create_channel_canvas' }, + { label: 'Open View', id: 'open_view' }, + { label: 'Update View', id: 'update_view' }, + { label: 'Push View', id: 'push_view' }, + { label: 'Publish View', id: 'publish_view' }, ], value: () => 'send', }, @@ -146,7 +150,17 @@ export const SlackBlock: BlockConfig = { } return { field: 'operation', - value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'], + value: [ + 'list_channels', + 'list_users', + 'get_user', + 'get_user_presence', + 'edit_canvas', + 'open_view', + 'update_view', + 'push_view', + 'publish_view', + ], not: true, and: { field: 'destinationType', @@ -171,7 +185,17 @@ export const SlackBlock: BlockConfig = { } return { field: 'operation', - value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'], + value: [ + 'list_channels', + 'list_users', + 'get_user', + 'get_user_presence', + 'edit_canvas', + 'open_view', + 'update_view', + 'push_view', + 'publish_view', + ], not: true, and: { field: 'destinationType', @@ -804,6 +828,157 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, value: 'create_channel_canvas', }, }, + // Open View / Push View specific fields + { + id: 'viewTriggerId', + title: 'Trigger ID', + type: 'short-input', + placeholder: 'Trigger ID from interaction payload', + condition: { + field: 'operation', + value: ['open_view', 'push_view'], + }, + required: true, + }, + { + id: 'viewInteractivityPointer', + title: 'Interactivity Pointer', + type: 'short-input', + placeholder: 'Alternative to trigger_id (optional)', + condition: { + field: 'operation', + value: ['open_view', 'push_view'], + }, + mode: 'advanced', + }, + // Update View specific fields + { + id: 'viewId', + title: 'View ID', + type: 'short-input', + placeholder: 'Unique view identifier (either View ID or External ID required)', + condition: { + field: 'operation', + value: 'update_view', + }, + }, + { + id: 'viewExternalId', + title: 'External ID', + type: 'short-input', + placeholder: 'Developer-set unique identifier (max 255 chars)', + condition: { + field: 'operation', + value: 'update_view', + }, + }, + // Update View / Publish View hash field + { + id: 'viewHash', + title: 'View Hash', + type: 'short-input', + placeholder: 'View state hash for race condition protection', + condition: { + field: 'operation', + value: ['update_view', 'publish_view'], + }, + mode: 'advanced', + }, + // Publish View specific fields + { + id: 'publishUserId', + title: 'User', + type: 'user-selector', + canonicalParamId: 'publishUserId', + serviceId: 'slack', + selectorKey: 'slack.users', + placeholder: 'Select user to publish Home tab to', + mode: 'basic', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + condition: { + field: 'operation', + value: 'publish_view', + }, + required: true, + }, + { + id: 'manualPublishUserId', + title: 'User ID', + type: 'short-input', + canonicalParamId: 'publishUserId', + placeholder: 'Enter Slack user ID (e.g., U0BPQUNTA)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'publish_view', + }, + required: true, + }, + // View payload (shared across all view operations) + { + id: 'viewPayload', + title: 'View Payload', + type: 'code', + language: 'json', + placeholder: 'JSON view payload with type, title, and blocks', + condition: { + field: 'operation', + value: ['open_view', 'update_view', 'push_view', 'publish_view'], + }, + required: true, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at Slack Block Kit views. +Generate ONLY a valid JSON view payload object based on the user's request. +The output MUST be a JSON object starting with { and ending with }. + +Current view: {context} + +The view object must include: +- "type": "modal" (for open/update/push) or "home" (for publish) +- "title": { "type": "plain_text", "text": "Title text", "emoji": true } (max 24 chars) +- "blocks": Array of Block Kit blocks + +Optional fields: +- "submit": { "type": "plain_text", "text": "Submit" } - Submit button text +- "close": { "type": "plain_text", "text": "Cancel" } - Close button text +- "private_metadata": String up to 3000 chars +- "callback_id": String identifier for interaction handling +- "clear_on_close": true/false +- "notify_on_close": true/false +- "external_id": Unique string per workspace (max 255 chars) + +Available block types: +- "section": Text with optional accessory. Text uses { "type": "mrkdwn", "text": "..." } or { "type": "plain_text", "text": "..." } +- "input": Form input with a label and element (plain_text_input, static_select, multi_static_select, datepicker, timepicker, checkboxes, radio_buttons) +- "header": Large text header (plain_text only) +- "divider": Horizontal rule separator +- "image": Requires "image_url" and "alt_text" +- "context": Contextual info with "elements" array +- "actions": Interactive elements like buttons + +Example modal: +{ + "type": "modal", + "title": { "type": "plain_text", "text": "My Form" }, + "submit": { "type": "plain_text", "text": "Submit" }, + "close": { "type": "plain_text", "text": "Cancel" }, + "blocks": [ + { + "type": "input", + "block_id": "input_1", + "label": { "type": "plain_text", "text": "Name" }, + "element": { "type": "plain_text_input", "action_id": "name_input" } + } + ] +} + +You can reference workflow variables using angle brackets, e.g., . +Do not include any explanations, markdown formatting, or other text outside the JSON object.`, + placeholder: 'Describe the view/modal you want to create...', + }, + }, ...getTrigger('slack_webhook').subBlocks, ], tools: { @@ -827,6 +1002,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, 'slack_get_user_presence', 'slack_edit_canvas', 'slack_create_channel_canvas', + 'slack_open_view', + 'slack_update_view', + 'slack_push_view', + 'slack_publish_view', ], config: { tool: (params) => { @@ -869,6 +1048,14 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, return 'slack_edit_canvas' case 'create_channel_canvas': return 'slack_create_channel_canvas' + case 'open_view': + return 'slack_open_view' + case 'update_view': + return 'slack_update_view' + case 'push_view': + return 'slack_push_view' + case 'publish_view': + return 'slack_publish_view' default: throw new Error(`Invalid Slack operation: ${params.operation}`) } @@ -915,6 +1102,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, canvasTitle, channelCanvasTitle, channelCanvasContent, + viewTriggerId, + viewInteractivityPointer, + viewId, + viewExternalId, + viewHash, + publishUserId, + viewPayload, ...rest } = params @@ -1081,6 +1275,43 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, baseParams.content = channelCanvasContent } break + + case 'open_view': + baseParams.triggerId = viewTriggerId + if (viewInteractivityPointer) { + baseParams.interactivityPointer = viewInteractivityPointer + } + baseParams.view = viewPayload + break + + case 'update_view': + if (viewId) { + baseParams.viewId = viewId + } + if (viewExternalId) { + baseParams.externalId = viewExternalId + } + if (viewHash) { + baseParams.hash = viewHash + } + baseParams.view = viewPayload + break + + case 'push_view': + baseParams.triggerId = viewTriggerId + if (viewInteractivityPointer) { + baseParams.interactivityPointer = viewInteractivityPointer + } + baseParams.view = viewPayload + break + + case 'publish_view': + baseParams.userId = publishUserId + if (viewHash) { + baseParams.hash = viewHash + } + baseParams.view = viewPayload + break } return baseParams @@ -1148,6 +1379,23 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, // Create Channel Canvas inputs channelCanvasTitle: { type: 'string', description: 'Title for channel canvas' }, channelCanvasContent: { type: 'string', description: 'Content for channel canvas' }, + // View operation inputs + viewTriggerId: { type: 'string', description: 'Trigger ID from interaction payload' }, + viewInteractivityPointer: { + type: 'string', + description: 'Alternative to trigger_id for posting to user', + }, + viewId: { type: 'string', description: 'Unique view identifier for update' }, + viewExternalId: { + type: 'string', + description: 'Developer-set unique identifier for update (max 255 chars)', + }, + viewHash: { type: 'string', description: 'View state hash for race condition protection' }, + publishUserId: { + type: 'string', + description: 'User ID to publish Home tab view to', + }, + viewPayload: { type: 'json', description: 'View payload object with type, title, and blocks' }, }, outputs: { // slack_message outputs (send operation) @@ -1281,6 +1529,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, 'Unix timestamp of last detected activity (only available when checking own presence)', }, + // View operation outputs (open_view, update_view, push_view, publish_view) + view: { + type: 'json', + description: + 'View object with properties: id, team_id, type, title, submit, close, blocks, private_metadata, callback_id, external_id, state, hash, clear_on_close, notify_on_close, root_view_id, previous_view_id, app_id, bot_id', + }, + // Trigger outputs (when used as webhook trigger) event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' }, channel_name: { type: 'string', description: 'Human-readable channel name' }, diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 952e9ee3e60..47b0f26084f 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -37,13 +37,13 @@ describe('migrateSubblockIds', () => { const { blocks, migrated } = migrateSubblockIds(input) expect(migrated).toBe(true) - expect(blocks['b1'].subBlocks['knowledgeBaseSelector']).toEqual({ + expect(blocks.b1.subBlocks.knowledgeBaseSelector).toEqual({ id: 'knowledgeBaseSelector', type: 'knowledge-base-selector', value: 'kb-uuid-123', }) - expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined() - expect(blocks['b1'].subBlocks['operation'].value).toBe('search') + expect(blocks.b1.subBlocks.knowledgeBaseId).toBeUndefined() + expect(blocks.b1.subBlocks.operation.value).toBe('search') }) it('should prefer new key when both old and new exist', () => { @@ -68,8 +68,8 @@ describe('migrateSubblockIds', () => { const { blocks, migrated } = migrateSubblockIds(input) expect(migrated).toBe(true) - expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('fresh-kb') - expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined() + expect(blocks.b1.subBlocks.knowledgeBaseSelector.value).toBe('fresh-kb') + expect(blocks.b1.subBlocks.knowledgeBaseId).toBeUndefined() }) it('should not touch blocks that already use the new key', () => { @@ -89,7 +89,7 @@ describe('migrateSubblockIds', () => { const { blocks, migrated } = migrateSubblockIds(input) expect(migrated).toBe(false) - expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-uuid') + expect(blocks.b1.subBlocks.knowledgeBaseSelector.value).toBe('kb-uuid') }) }) @@ -109,8 +109,8 @@ describe('migrateSubblockIds', () => { const { blocks } = migrateSubblockIds(input) - expect(input['b1'].subBlocks['knowledgeBaseId']).toBeDefined() - expect(blocks['b1'].subBlocks['knowledgeBaseSelector']).toBeDefined() + expect(input.b1.subBlocks.knowledgeBaseId).toBeDefined() + expect(blocks.b1.subBlocks.knowledgeBaseSelector).toBeDefined() expect(blocks).not.toBe(input) }) @@ -127,7 +127,7 @@ describe('migrateSubblockIds', () => { const { blocks, migrated } = migrateSubblockIds(input) expect(migrated).toBe(false) - expect(blocks['b1'].subBlocks['code'].value).toBe('console.log("hi")') + expect(blocks.b1.subBlocks.code.value).toBe('console.log("hi")') }) it('should migrate multiple blocks in one pass', () => { @@ -166,9 +166,9 @@ describe('migrateSubblockIds', () => { const { blocks, migrated } = migrateSubblockIds(input) expect(migrated).toBe(true) - expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-1') - expect(blocks['b2'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-2') - expect(blocks['b3'].subBlocks['code']).toBeDefined() + expect(blocks.b1.subBlocks.knowledgeBaseSelector.value).toBe('kb-1') + expect(blocks.b2.subBlocks.knowledgeBaseSelector.value).toBe('kb-2') + expect(blocks.b3.subBlocks.code).toBeDefined() }) it('should handle blocks with empty subBlocks', () => { diff --git a/apps/sim/tools/reddit/delete.ts b/apps/sim/tools/reddit/delete.ts index 2f296517d93..749a5e2a5fb 100644 --- a/apps/sim/tools/reddit/delete.ts +++ b/apps/sim/tools/reddit/delete.ts @@ -46,9 +46,7 @@ export const deleteTool: ToolConfig = { id: params.id, }) - return { - body: formData.toString(), - } + return formData.toString() as unknown as Record }, }, diff --git a/apps/sim/tools/reddit/get_comments.ts b/apps/sim/tools/reddit/get_comments.ts index 83bbfe563bf..b22366cdf17 100644 --- a/apps/sim/tools/reddit/get_comments.ts +++ b/apps/sim/tools/reddit/get_comments.ts @@ -1,3 +1,4 @@ +import { validatePathSegment } from '@/lib/core/security/input-validation' import type { RedditCommentsParams, RedditCommentsResponse } from '@/tools/reddit/types' import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' @@ -69,12 +70,6 @@ export const getCommentsTool: ToolConfig { const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'confidence' - const limit = Math.min(Math.max(1, params.limit || 50), 100) + const limit = Math.min(Math.max(1, params.limit ?? 50), 100) // Build URL with query parameters const urlParams = new URLSearchParams({ @@ -126,18 +109,21 @@ export const getCommentsTool: ToolConfig { @@ -157,7 +143,7 @@ export const getCommentsTool: ToolConfig { const subreddit = normalizeSubreddit(params.subreddit) - const limit = Math.min(Math.max(1, params.limit || 10), 100) + const limit = Math.min(Math.max(1, params.limit ?? 10), 100) // Build URL with appropriate parameters using OAuth endpoint const urlParams = new URLSearchParams({ @@ -115,25 +115,26 @@ export const getControversialTool: ToolConfig { const post = child.data || {} return { - id: post.id || '', - title: post.title || '', + id: post.id ?? '', + name: post.name ?? '', + title: post.title ?? '', author: post.author || '[deleted]', - url: post.url || '', + url: post.url ?? '', permalink: post.permalink ? `https://www.reddit.com${post.permalink}` : '', - created_utc: post.created_utc || 0, - score: post.score || 0, - num_comments: post.num_comments || 0, + created_utc: post.created_utc ?? 0, + score: post.score ?? 0, + num_comments: post.num_comments ?? 0, is_self: !!post.is_self, - selftext: post.selftext || '', - thumbnail: post.thumbnail || '', - subreddit: post.subreddit || subredditName, + selftext: post.selftext ?? '', + thumbnail: post.thumbnail ?? '', + subreddit: post.subreddit ?? subredditName, } }) || [] @@ -142,6 +143,8 @@ export const getControversialTool: ToolConfig = { + id: 'reddit_get_me', + name: 'Get Reddit User Identity', + description: 'Get information about the authenticated Reddit user', + version: '1.0.0', + + oauth: { + required: true, + provider: 'reddit', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Reddit API', + }, + }, + + request: { + url: () => 'https://oauth.reddit.com/api/v1/me?raw_json=1', + method: 'GET', + headers: (params: RedditGetMeParams) => { + if (!params.accessToken) { + throw new Error('Access token is required for Reddit API') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'User-Agent': 'sim-studio/1.0 (https://github.com/simstudioai/sim)', + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { + id: '', + name: '', + created_utc: 0, + link_karma: 0, + comment_karma: 0, + total_karma: 0, + is_gold: false, + is_mod: false, + has_verified_email: false, + icon_img: '', + }, + } + } + + return { + success: true, + output: { + id: data.id ?? '', + name: data.name ?? '', + created_utc: data.created_utc ?? 0, + link_karma: data.link_karma ?? 0, + comment_karma: data.comment_karma ?? 0, + total_karma: data.total_karma ?? 0, + is_gold: data.is_gold ?? false, + is_mod: data.is_mod ?? false, + has_verified_email: data.has_verified_email ?? false, + icon_img: data.icon_img ?? '', + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + name: { type: 'string', description: 'Username' }, + created_utc: { type: 'number', description: 'Account creation time in UTC epoch seconds' }, + link_karma: { type: 'number', description: 'Total link karma' }, + comment_karma: { type: 'number', description: 'Total comment karma' }, + total_karma: { type: 'number', description: 'Combined total karma' }, + is_gold: { type: 'boolean', description: 'Whether user has Reddit Premium' }, + is_mod: { type: 'boolean', description: 'Whether user is a moderator' }, + has_verified_email: { type: 'boolean', description: 'Whether email is verified' }, + icon_img: { type: 'string', description: 'User avatar/icon URL' }, + }, +} diff --git a/apps/sim/tools/reddit/get_messages.ts b/apps/sim/tools/reddit/get_messages.ts new file mode 100644 index 00000000000..c0dae039480 --- /dev/null +++ b/apps/sim/tools/reddit/get_messages.ts @@ -0,0 +1,188 @@ +import { validateEnum } from '@/lib/core/security/input-validation' +import type { RedditGetMessagesParams, RedditMessagesResponse } from '@/tools/reddit/types' +import type { ToolConfig } from '@/tools/types' + +const ALLOWED_MESSAGE_FOLDERS = [ + 'inbox', + 'unread', + 'sent', + 'messages', + 'comments', + 'selfreply', + 'mentions', +] as const + +export const getMessagesTool: ToolConfig = { + id: 'reddit_get_messages', + name: 'Get Reddit Messages', + description: 'Retrieve private messages from your Reddit inbox', + version: '1.0.0', + + oauth: { + required: true, + provider: 'reddit', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Reddit API', + }, + where: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Message folder to retrieve: "inbox" (all), "unread", "sent", "messages" (direct messages only), "comments" (comment replies), "selfreply" (self-post replies), or "mentions" (username mentions). Default: "inbox"', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of messages to return (e.g., 25). Default: 25, max: 100', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Fullname of a thing to fetch items after (for pagination)', + }, + before: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Fullname of a thing to fetch items before (for pagination)', + }, + mark: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to mark fetched messages as read', + }, + count: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'A count of items already seen in the listing (used for numbering)', + }, + show: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Show items that would normally be filtered (e.g., "all")', + }, + }, + + request: { + url: (params: RedditGetMessagesParams) => { + const where = params.where || 'inbox' + const validation = validateEnum(where, ALLOWED_MESSAGE_FOLDERS, 'where') + if (!validation.isValid) { + throw new Error(validation.error) + } + const limit = Math.min(Math.max(1, params.limit ?? 25), 100) + + const urlParams = new URLSearchParams({ + limit: limit.toString(), + raw_json: '1', + }) + + if (params.after) urlParams.append('after', params.after) + if (params.before) urlParams.append('before', params.before) + if (params.mark !== undefined) urlParams.append('mark', params.mark.toString()) + if (params.count !== undefined) urlParams.append('count', Number(params.count).toString()) + if (params.show) urlParams.append('show', params.show) + + return `https://oauth.reddit.com/message/${where}?${urlParams.toString()}` + }, + method: 'GET', + headers: (params: RedditGetMessagesParams) => { + if (!params.accessToken) { + throw new Error('Access token is required for Reddit API') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'User-Agent': 'sim-studio/1.0 (https://github.com/simstudioai/sim)', + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { messages: [], after: null, before: null }, + } + } + + const messages = + data.data?.children?.map((child: any) => { + const msg = child.data || {} + return { + id: msg.id ?? '', + name: msg.name ?? '', + author: msg.author ?? '', + dest: msg.dest ?? '', + subject: msg.subject ?? '', + body: msg.body ?? '', + created_utc: msg.created_utc ?? 0, + new: msg.new ?? false, + was_comment: msg.was_comment ?? false, + context: msg.context ?? '', + distinguished: msg.distinguished ?? null, + } + }) || [] + + return { + success: true, + output: { + messages, + after: data.data?.after ?? null, + before: data.data?.before ?? null, + }, + } + }, + + outputs: { + messages: { + type: 'array', + description: 'Array of messages with sender, recipient, subject, body, and metadata', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Message ID' }, + name: { type: 'string', description: 'Thing fullname (t4_xxxxx)' }, + author: { type: 'string', description: 'Sender username' }, + dest: { type: 'string', description: 'Recipient username' }, + subject: { type: 'string', description: 'Message subject' }, + body: { type: 'string', description: 'Message body text' }, + created_utc: { type: 'number', description: 'Creation time in UTC epoch seconds' }, + new: { type: 'boolean', description: 'Whether the message is unread' }, + was_comment: { type: 'boolean', description: 'Whether the message is a comment reply' }, + context: { type: 'string', description: 'Context URL for comment replies' }, + distinguished: { + type: 'string', + description: 'Distinction: null/"moderator"/"admin"', + optional: true, + }, + }, + }, + }, + after: { + type: 'string', + description: 'Fullname of the last item for forward pagination', + optional: true, + }, + before: { + type: 'string', + description: 'Fullname of the first item for backward pagination', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/reddit/get_posts.ts b/apps/sim/tools/reddit/get_posts.ts index 9afb170a200..b3e9f783e4f 100644 --- a/apps/sim/tools/reddit/get_posts.ts +++ b/apps/sim/tools/reddit/get_posts.ts @@ -1,7 +1,10 @@ +import { validateEnum } from '@/lib/core/security/input-validation' import type { RedditPostsParams, RedditPostsResponse } from '@/tools/reddit/types' import { normalizeSubreddit } from '@/tools/reddit/utils' import type { ToolConfig } from '@/tools/types' +const ALLOWED_SORT_OPTIONS = ['hot', 'new', 'top', 'controversial', 'rising'] as const + export const getPostsTool: ToolConfig = { id: 'reddit_get_posts', name: 'Get Reddit Posts', @@ -30,7 +33,8 @@ export const getPostsTool: ToolConfig = type: 'string', required: false, visibility: 'user-or-llm', - description: 'Sort method for posts (e.g., "hot", "new", "top", "rising"). Default: "hot"', + description: + 'Sort method for posts (e.g., "hot", "new", "top", "rising", "controversial"). Default: "hot"', }, limit: { type: 'number', @@ -43,7 +47,7 @@ export const getPostsTool: ToolConfig = required: false, visibility: 'user-or-llm', description: - 'Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" (default: "day")', + 'Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" (default: "all")', }, after: { type: 'string', @@ -75,13 +79,23 @@ export const getPostsTool: ToolConfig = visibility: 'user-or-llm', description: 'Expand subreddit details in the response', }, + g: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Geo filter for posts (e.g., "GLOBAL", "US", "AR", etc.)', + }, }, request: { url: (params: RedditPostsParams) => { const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'hot' - const limit = Math.min(Math.max(1, params.limit || 10), 100) + const sortValidation = validateEnum(sort, ALLOWED_SORT_OPTIONS, 'sort') + if (!sortValidation.isValid) { + throw new Error(sortValidation.error) + } + const limit = Math.min(Math.max(1, params.limit ?? 10), 100) // Build URL with appropriate parameters using OAuth endpoint const urlParams = new URLSearchParams({ @@ -89,8 +103,12 @@ export const getPostsTool: ToolConfig = raw_json: '1', }) - // Add time parameter only for 'top' sorting - if (sort === 'top' && params.time !== undefined && params.time !== null) { + // Add time parameter for 'top' and 'controversial' sorting + if ( + (sort === 'top' || sort === 'controversial') && + params.time !== undefined && + params.time !== null + ) { urlParams.append('t', params.time) } @@ -105,6 +123,7 @@ export const getPostsTool: ToolConfig = urlParams.append('show', params.show) if (params.sr_detail !== undefined && params.sr_detail !== null) urlParams.append('sr_detail', params.sr_detail.toString()) + if (params.g) urlParams.append('g', params.g) return `https://oauth.reddit.com/r/${subreddit}/${sort}?${urlParams.toString()}` }, @@ -127,25 +146,26 @@ export const getPostsTool: ToolConfig = // Extract subreddit name from response (with fallback) const subredditName = - data.data?.children[0]?.data?.subreddit || requestParams?.subreddit || 'unknown' + data.data?.children?.[0]?.data?.subreddit || requestParams?.subreddit || 'unknown' // Transform posts data const posts = data.data?.children?.map((child: any) => { const post = child.data || {} return { - id: post.id || '', - title: post.title || '', + id: post.id ?? '', + name: post.name ?? '', + title: post.title ?? '', author: post.author || '[deleted]', - url: post.url || '', + url: post.url ?? '', permalink: post.permalink ? `https://www.reddit.com${post.permalink}` : '', - created_utc: post.created_utc || 0, - score: post.score || 0, - num_comments: post.num_comments || 0, + created_utc: post.created_utc ?? 0, + score: post.score ?? 0, + num_comments: post.num_comments ?? 0, is_self: !!post.is_self, - selftext: post.selftext || '', - thumbnail: post.thumbnail || '', - subreddit: post.subreddit || subredditName, + selftext: post.selftext ?? '', + thumbnail: post.thumbnail ?? '', + subreddit: post.subreddit ?? subredditName, } }) || [] @@ -154,6 +174,8 @@ export const getPostsTool: ToolConfig = output: { subreddit: subredditName, posts, + after: data.data?.after ?? null, + before: data.data?.before ?? null, }, } }, @@ -170,6 +192,7 @@ export const getPostsTool: ToolConfig = type: 'object', properties: { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title' }, author: { type: 'string', description: 'Author username' }, url: { type: 'string', description: 'Post URL' }, @@ -184,5 +207,15 @@ export const getPostsTool: ToolConfig = }, }, }, + after: { + type: 'string', + description: 'Fullname of the last item for forward pagination', + optional: true, + }, + before: { + type: 'string', + description: 'Fullname of the first item for backward pagination', + optional: true, + }, }, } diff --git a/apps/sim/tools/reddit/get_subreddit_info.ts b/apps/sim/tools/reddit/get_subreddit_info.ts new file mode 100644 index 00000000000..17d62b2e7be --- /dev/null +++ b/apps/sim/tools/reddit/get_subreddit_info.ts @@ -0,0 +1,126 @@ +import type { + RedditGetSubredditInfoParams, + RedditSubredditInfoResponse, +} from '@/tools/reddit/types' +import { normalizeSubreddit } from '@/tools/reddit/utils' +import type { ToolConfig } from '@/tools/types' + +export const getSubredditInfoTool: ToolConfig< + RedditGetSubredditInfoParams, + RedditSubredditInfoResponse +> = { + id: 'reddit_get_subreddit_info', + name: 'Get Subreddit Info', + description: 'Get metadata and information about a subreddit', + version: '1.0.0', + + oauth: { + required: true, + provider: 'reddit', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Reddit API', + }, + subreddit: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The subreddit to get info about (e.g., "technology", "programming", "news")', + }, + }, + + request: { + url: (params: RedditGetSubredditInfoParams) => { + const subreddit = normalizeSubreddit(params.subreddit) + return `https://oauth.reddit.com/r/${subreddit}/about?raw_json=1` + }, + method: 'GET', + headers: (params: RedditGetSubredditInfoParams) => { + if (!params.accessToken) { + throw new Error('Access token is required for Reddit API') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'User-Agent': 'sim-studio/1.0 (https://github.com/simstudioai/sim)', + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { + id: '', + name: '', + display_name: '', + title: '', + description: '', + public_description: '', + subscribers: 0, + accounts_active: 0, + created_utc: 0, + over18: false, + lang: '', + subreddit_type: '', + url: '', + icon_img: null, + banner_img: null, + }, + } + } + + const sub = data.data || data + + return { + success: true, + output: { + id: sub.id ?? '', + name: sub.name ?? '', + display_name: sub.display_name ?? '', + title: sub.title ?? '', + description: sub.description ?? '', + public_description: sub.public_description ?? '', + subscribers: sub.subscribers ?? 0, + accounts_active: sub.accounts_active ?? 0, + created_utc: sub.created_utc ?? 0, + over18: sub.over18 ?? false, + lang: sub.lang ?? '', + subreddit_type: sub.subreddit_type ?? '', + url: sub.url ?? '', + icon_img: sub.icon_img ?? null, + banner_img: sub.banner_img ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Subreddit ID' }, + name: { type: 'string', description: 'Subreddit fullname (t5_xxxxx)' }, + display_name: { type: 'string', description: 'Subreddit name without prefix' }, + title: { type: 'string', description: 'Subreddit title' }, + description: { type: 'string', description: 'Full subreddit description (markdown)' }, + public_description: { type: 'string', description: 'Short public description' }, + subscribers: { type: 'number', description: 'Number of subscribers' }, + accounts_active: { type: 'number', description: 'Number of currently active users' }, + created_utc: { type: 'number', description: 'Creation time in UTC epoch seconds' }, + over18: { type: 'boolean', description: 'Whether the subreddit is NSFW' }, + lang: { type: 'string', description: 'Primary language of the subreddit' }, + subreddit_type: { + type: 'string', + description: 'Subreddit type: public, private, restricted, etc.', + }, + url: { type: 'string', description: 'Subreddit URL path (e.g., /r/technology/)' }, + icon_img: { type: 'string', description: 'Subreddit icon URL', optional: true }, + banner_img: { type: 'string', description: 'Subreddit banner URL', optional: true }, + }, +} diff --git a/apps/sim/tools/reddit/get_user.ts b/apps/sim/tools/reddit/get_user.ts new file mode 100644 index 00000000000..cb14897b091 --- /dev/null +++ b/apps/sim/tools/reddit/get_user.ts @@ -0,0 +1,106 @@ +import { validatePathSegment } from '@/lib/core/security/input-validation' +import type { RedditGetUserParams, RedditUserResponse } from '@/tools/reddit/types' +import type { ToolConfig } from '@/tools/types' + +export const getUserTool: ToolConfig = { + id: 'reddit_get_user', + name: 'Get Reddit User Profile', + description: 'Get public profile information about any Reddit user by username', + version: '1.0.0', + + oauth: { + required: true, + provider: 'reddit', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Reddit API', + }, + username: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reddit username to look up (e.g., "spez", "example_user")', + }, + }, + + request: { + url: (params: RedditGetUserParams) => { + const username = params.username.trim().replace(/^u\//, '') + const validation = validatePathSegment(username, { paramName: 'username' }) + if (!validation.isValid) { + throw new Error(validation.error) + } + return `https://oauth.reddit.com/user/${username}/about?raw_json=1` + }, + method: 'GET', + headers: (params: RedditGetUserParams) => { + if (!params.accessToken) { + throw new Error('Access token is required for Reddit API') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'User-Agent': 'sim-studio/1.0 (https://github.com/simstudioai/sim)', + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { + id: '', + name: '', + created_utc: 0, + link_karma: 0, + comment_karma: 0, + total_karma: 0, + is_gold: false, + is_mod: false, + has_verified_email: false, + icon_img: '', + }, + } + } + + const user = data.data || data + + return { + success: true, + output: { + id: user.id ?? '', + name: user.name ?? '', + created_utc: user.created_utc ?? 0, + link_karma: user.link_karma ?? 0, + comment_karma: user.comment_karma ?? 0, + total_karma: user.total_karma ?? 0, + is_gold: user.is_gold ?? false, + is_mod: user.is_mod ?? false, + has_verified_email: user.has_verified_email ?? false, + icon_img: user.icon_img ?? '', + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + name: { type: 'string', description: 'Username' }, + created_utc: { type: 'number', description: 'Account creation time in UTC epoch seconds' }, + link_karma: { type: 'number', description: 'Total link karma' }, + comment_karma: { type: 'number', description: 'Total comment karma' }, + total_karma: { type: 'number', description: 'Combined total karma' }, + is_gold: { type: 'boolean', description: 'Whether user has Reddit Premium' }, + is_mod: { type: 'boolean', description: 'Whether user is a moderator' }, + has_verified_email: { type: 'boolean', description: 'Whether email is verified' }, + icon_img: { type: 'string', description: 'User avatar/icon URL' }, + }, +} diff --git a/apps/sim/tools/reddit/hot_posts.ts b/apps/sim/tools/reddit/hot_posts.ts index 0dfc486ecd6..30baa551447 100644 --- a/apps/sim/tools/reddit/hot_posts.ts +++ b/apps/sim/tools/reddit/hot_posts.ts @@ -43,7 +43,7 @@ export const hotPostsTool: ToolConfig = request: { url: (params) => { const subreddit = normalizeSubreddit(params.subreddit) - const limit = Math.min(Math.max(1, params.limit || 10), 100) + const limit = Math.min(Math.max(1, params.limit ?? 10), 100) return `https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1` }, @@ -65,25 +65,26 @@ export const hotPostsTool: ToolConfig = const data = await response.json() // Process the posts data with proper error handling - const posts: RedditPost[] = data.data.children.map((child: any) => { - const post = child.data || {} - return { - id: post.id || '', - title: post.title || '', - author: post.author || '[deleted]', - url: post.url || '', - permalink: post.permalink ? `https://www.reddit.com${post.permalink}` : '', - created_utc: post.created_utc || 0, - score: post.score || 0, - num_comments: post.num_comments || 0, - selftext: post.selftext || '', - thumbnail: - post.thumbnail !== 'self' && post.thumbnail !== 'default' ? post.thumbnail : undefined, - is_self: !!post.is_self, - subreddit: post.subreddit || requestParams?.subreddit || '', - subreddit_name_prefixed: post.subreddit_name_prefixed || '', - } - }) + const posts: RedditPost[] = + data.data?.children?.map((child: any) => { + const post = child.data || {} + return { + id: post.id ?? '', + name: post.name ?? '', + title: post.title ?? '', + author: post.author || '[deleted]', + url: post.url ?? '', + permalink: post.permalink ? `https://www.reddit.com${post.permalink}` : '', + created_utc: post.created_utc ?? 0, + score: post.score ?? 0, + num_comments: post.num_comments ?? 0, + selftext: post.selftext ?? '', + thumbnail: + post.thumbnail !== 'self' && post.thumbnail !== 'default' ? post.thumbnail : undefined, + is_self: !!post.is_self, + subreddit: post.subreddit ?? requestParams?.subreddit ?? '', + } + }) || [] // Extract the subreddit name from the response data with fallback const subreddit = @@ -95,6 +96,8 @@ export const hotPostsTool: ToolConfig = output: { subreddit, posts, + after: data.data?.after ?? null, + before: data.data?.before ?? null, }, } }, @@ -112,6 +115,7 @@ export const hotPostsTool: ToolConfig = type: 'object', properties: { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title' }, author: { type: 'string', description: 'Author username' }, url: { type: 'string', description: 'Post URL' }, @@ -123,9 +127,18 @@ export const hotPostsTool: ToolConfig = selftext: { type: 'string', description: 'Text content for self posts' }, thumbnail: { type: 'string', description: 'Thumbnail URL' }, subreddit: { type: 'string', description: 'Subreddit name' }, - subreddit_name_prefixed: { type: 'string', description: 'Subreddit name with r/ prefix' }, }, }, }, + after: { + type: 'string', + description: 'Fullname of the last item for forward pagination', + optional: true, + }, + before: { + type: 'string', + description: 'Fullname of the first item for backward pagination', + optional: true, + }, }, } diff --git a/apps/sim/tools/reddit/index.ts b/apps/sim/tools/reddit/index.ts index ca1a816703c..23022b19b5f 100644 --- a/apps/sim/tools/reddit/index.ts +++ b/apps/sim/tools/reddit/index.ts @@ -2,11 +2,16 @@ import { deleteTool } from '@/tools/reddit/delete' import { editTool } from '@/tools/reddit/edit' import { getCommentsTool } from '@/tools/reddit/get_comments' import { getControversialTool } from '@/tools/reddit/get_controversial' +import { getMeTool } from '@/tools/reddit/get_me' +import { getMessagesTool } from '@/tools/reddit/get_messages' import { getPostsTool } from '@/tools/reddit/get_posts' +import { getSubredditInfoTool } from '@/tools/reddit/get_subreddit_info' +import { getUserTool } from '@/tools/reddit/get_user' import { hotPostsTool } from '@/tools/reddit/hot_posts' import { replyTool } from '@/tools/reddit/reply' import { saveTool, unsaveTool } from '@/tools/reddit/save' import { searchTool } from '@/tools/reddit/search' +import { sendMessageTool } from '@/tools/reddit/send_message' import { submitPostTool } from '@/tools/reddit/submit_post' import { subscribeTool } from '@/tools/reddit/subscribe' import { voteTool } from '@/tools/reddit/vote' @@ -24,3 +29,8 @@ export const redditReplyTool = replyTool export const redditEditTool = editTool export const redditDeleteTool = deleteTool export const redditSubscribeTool = subscribeTool +export const redditGetMeTool = getMeTool +export const redditGetUserTool = getUserTool +export const redditSendMessageTool = sendMessageTool +export const redditGetMessagesTool = getMessagesTool +export const redditGetSubredditInfoTool = getSubredditInfoTool diff --git a/apps/sim/tools/reddit/reply.ts b/apps/sim/tools/reddit/reply.ts index d22fceb07e5..7a29af3aa91 100644 --- a/apps/sim/tools/reddit/reply.ts +++ b/apps/sim/tools/reddit/reply.ts @@ -32,6 +32,12 @@ export const replyTool: ToolConfig = { visibility: 'user-or-llm', description: 'Comment text in markdown format (e.g., "Great post! Here is my **reply**")', }, + return_rtjson: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return response in Rich Text JSON format', + }, }, request: { @@ -55,6 +61,9 @@ export const replyTool: ToolConfig = { api_type: 'json', }) + if (params.return_rtjson !== undefined) + formData.append('return_rtjson', params.return_rtjson.toString()) + return formData.toString() as unknown as Record }, }, @@ -62,6 +71,17 @@ export const replyTool: ToolConfig = { transformResponse: async (response: Response) => { const data = await response.json() + if (!response.ok) { + const errorMsg = data?.message || `HTTP error ${response.status}` + return { + success: false, + output: { + success: false, + message: `Failed to post reply: ${errorMsg}`, + }, + } + } + // Reddit API returns errors in json.errors array if (data.json?.errors && data.json.errors.length > 0) { const errors = data.json.errors.map((err: any) => err.join(': ')).join(', ') diff --git a/apps/sim/tools/reddit/search.ts b/apps/sim/tools/reddit/search.ts index c2bcafebef1..4e51c2aa5ca 100644 --- a/apps/sim/tools/reddit/search.ts +++ b/apps/sim/tools/reddit/search.ts @@ -83,13 +83,26 @@ export const searchTool: ToolConfig = { visibility: 'user-or-llm', description: 'Show items that would normally be filtered (e.g., "all")', }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Type of search results: "link" (posts), "sr" (subreddits), or "user" (users). Default: "link"', + }, + sr_detail: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Expand subreddit details in the response', + }, }, request: { url: (params: RedditSearchParams) => { const subreddit = normalizeSubreddit(params.subreddit) const sort = params.sort || 'relevance' - const limit = Math.min(Math.max(1, params.limit || 10), 100) + const limit = Math.min(Math.max(1, params.limit ?? 10), 100) const restrict_sr = params.restrict_sr !== false // Default to true // Build URL with appropriate parameters using OAuth endpoint @@ -111,6 +124,8 @@ export const searchTool: ToolConfig = { if (params.before) urlParams.append('before', params.before) if (params.count !== undefined) urlParams.append('count', Number(params.count).toString()) if (params.show) urlParams.append('show', params.show) + if (params.type) urlParams.append('type', params.type) + if (params.sr_detail !== undefined) urlParams.append('sr_detail', params.sr_detail.toString()) return `https://oauth.reddit.com/r/${subreddit}/search?${urlParams.toString()}` }, @@ -133,25 +148,26 @@ export const searchTool: ToolConfig = { // Extract subreddit name from response (with fallback) const subredditName = - data.data?.children[0]?.data?.subreddit || requestParams?.subreddit || 'unknown' + data.data?.children?.[0]?.data?.subreddit || requestParams?.subreddit || 'unknown' // Transform posts data const posts = data.data?.children?.map((child: any) => { const post = child.data || {} return { - id: post.id || '', - title: post.title || '', + id: post.id ?? '', + name: post.name ?? '', + title: post.title ?? '', author: post.author || '[deleted]', - url: post.url || '', + url: post.url ?? '', permalink: post.permalink ? `https://www.reddit.com${post.permalink}` : '', - created_utc: post.created_utc || 0, - score: post.score || 0, - num_comments: post.num_comments || 0, + created_utc: post.created_utc ?? 0, + score: post.score ?? 0, + num_comments: post.num_comments ?? 0, is_self: !!post.is_self, - selftext: post.selftext || '', - thumbnail: post.thumbnail || '', - subreddit: post.subreddit || subredditName, + selftext: post.selftext ?? '', + thumbnail: post.thumbnail ?? '', + subreddit: post.subreddit ?? subredditName, } }) || [] @@ -160,6 +176,8 @@ export const searchTool: ToolConfig = { output: { subreddit: subredditName, posts, + after: data.data?.after ?? null, + before: data.data?.before ?? null, }, } }, @@ -177,6 +195,7 @@ export const searchTool: ToolConfig = { type: 'object', properties: { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title' }, author: { type: 'string', description: 'Author username' }, url: { type: 'string', description: 'Post URL' }, @@ -191,5 +210,15 @@ export const searchTool: ToolConfig = { }, }, }, + after: { + type: 'string', + description: 'Fullname of the last item for forward pagination', + optional: true, + }, + before: { + type: 'string', + description: 'Fullname of the first item for backward pagination', + optional: true, + }, }, } diff --git a/apps/sim/tools/reddit/send_message.ts b/apps/sim/tools/reddit/send_message.ts new file mode 100644 index 00000000000..0c20330b9c1 --- /dev/null +++ b/apps/sim/tools/reddit/send_message.ts @@ -0,0 +1,122 @@ +import type { RedditSendMessageParams, RedditWriteResponse } from '@/tools/reddit/types' +import type { ToolConfig } from '@/tools/types' + +export const sendMessageTool: ToolConfig = { + id: 'reddit_send_message', + name: 'Send Reddit Message', + description: 'Send a private message to a Reddit user', + version: '1.0.0', + + oauth: { + required: true, + provider: 'reddit', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Reddit API', + }, + to: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Recipient username (e.g., "example_user") or subreddit (e.g., "/r/subreddit")', + }, + subject: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message subject (max 100 characters)', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message body in markdown format', + }, + from_sr: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Subreddit name to send the message from (requires moderator mail permission)', + }, + }, + + request: { + url: () => 'https://oauth.reddit.com/api/compose', + method: 'POST', + headers: (params: RedditSendMessageParams) => { + if (!params.accessToken) { + throw new Error('Access token is required for Reddit API') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'User-Agent': 'sim-studio/1.0 (https://github.com/simstudioai/sim)', + 'Content-Type': 'application/x-www-form-urlencoded', + } + }, + body: (params: RedditSendMessageParams) => { + const formData = new URLSearchParams({ + to: params.to.trim(), + subject: params.subject, + text: params.text, + api_type: 'json', + }) + + if (params.from_sr) { + formData.append('from_sr', params.from_sr.trim()) + } + + return formData.toString() as unknown as Record + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + const errorMsg = data?.message || `HTTP error ${response.status}` + return { + success: false, + output: { + success: false, + message: `Failed to send message: ${errorMsg}`, + }, + } + } + + if (data.json?.errors && data.json.errors.length > 0) { + const errors = data.json.errors.map((err: any) => err.join(': ')).join(', ') + return { + success: false, + output: { + success: false, + message: `Failed to send message: ${errors}`, + }, + } + } + + return { + success: true, + output: { + success: true, + message: 'Message sent successfully', + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the message was sent successfully', + }, + message: { + type: 'string', + description: 'Success or error message', + }, + }, +} diff --git a/apps/sim/tools/reddit/submit_post.ts b/apps/sim/tools/reddit/submit_post.ts index e08bae50a26..88cce3e8c37 100644 --- a/apps/sim/tools/reddit/submit_post.ts +++ b/apps/sim/tools/reddit/submit_post.ts @@ -64,6 +64,24 @@ export const submitPostTool: ToolConfig visibility: 'user-or-llm', description: 'Send reply notifications to inbox (default: true)', }, + flair_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Flair template UUID for the post (max 36 characters)', + }, + flair_text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Flair text to display on the post (max 64 characters)', + }, + collection_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Collection UUID to add the post to', + }, }, request: { @@ -105,6 +123,9 @@ export const submitPostTool: ToolConfig // Add optional parameters if (params.nsfw !== undefined) formData.append('nsfw', params.nsfw.toString()) if (params.spoiler !== undefined) formData.append('spoiler', params.spoiler.toString()) + if (params.flair_id) formData.append('flair_id', params.flair_id) + if (params.flair_text) formData.append('flair_text', params.flair_text) + if (params.collection_id) formData.append('collection_id', params.collection_id) if (params.send_replies !== undefined) formData.append('sendreplies', params.send_replies.toString()) @@ -138,7 +159,9 @@ export const submitPostTool: ToolConfig id: postData?.id, name: postData?.name, url: postData?.url, - permalink: `https://www.reddit.com${postData?.url}`, + permalink: postData?.permalink + ? `https://www.reddit.com${postData.permalink}` + : (postData?.url ?? ''), }, }, } diff --git a/apps/sim/tools/reddit/types.ts b/apps/sim/tools/reddit/types.ts index 40a7b985873..3573ea80607 100644 --- a/apps/sim/tools/reddit/types.ts +++ b/apps/sim/tools/reddit/types.ts @@ -11,12 +11,14 @@ import type { OutputProperty, ToolResponse } from '@/tools/types' */ export const POST_OUTPUT_PROPERTIES = { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title (may contain newlines)' }, author: { type: 'string', description: 'Poster account name (null for promotional links)' }, url: { type: 'string', description: 'External link URL or self-post permalink' }, permalink: { type: 'string', description: 'Relative permanent link URL' }, created_utc: { type: 'number', description: 'Creation time in UTC epoch seconds' }, score: { type: 'number', description: 'Net upvotes minus downvotes' }, + upvote_ratio: { type: 'number', description: 'Ratio of upvotes to total votes' }, num_comments: { type: 'number', description: 'Total comments including removed ones' }, is_self: { type: 'boolean', description: 'Indicates self-post vs external link' }, selftext: { @@ -55,6 +57,7 @@ export const POST_OUTPUT_PROPERTIES = { */ export const COMMENT_OUTPUT_PROPERTIES = { id: { type: 'string', description: 'Comment ID' }, + name: { type: 'string', description: 'Thing fullname (t1_xxxxx)' }, author: { type: 'string', description: 'Commenter account name' }, body: { type: 'string', description: 'Raw unformatted comment text with markup characters' }, body_html: { type: 'string', description: 'Formatted HTML version of comment' }, @@ -70,6 +73,7 @@ export const COMMENT_OUTPUT_PROPERTIES = { type: 'string', description: 'Distinction: null/"moderator"/"admin"/"special"', }, + is_submitter: { type: 'boolean', description: 'Whether commenter is the post author' }, ups: { type: 'number', description: 'Upvote count' }, downs: { type: 'number', description: 'Downvote count' }, likes: { type: 'boolean', description: 'User vote: true (up), false (down), null (none)' }, @@ -88,6 +92,7 @@ export const COMMENT_OUTPUT_PROPERTIES = { */ export const POST_LISTING_OUTPUT_PROPERTIES = { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title' }, author: { type: 'string', description: 'Author username' }, url: { type: 'string', description: 'Post URL' }, @@ -106,6 +111,7 @@ export const POST_LISTING_OUTPUT_PROPERTIES = { */ export const COMMENT_LISTING_OUTPUT_PROPERTIES = { id: { type: 'string', description: 'Comment ID' }, + name: { type: 'string', description: 'Thing fullname (t1_xxxxx)' }, author: { type: 'string', description: 'Comment author' }, body: { type: 'string', description: 'Comment text' }, score: { type: 'number', description: 'Comment score' }, @@ -130,6 +136,7 @@ export const COMMENT_WITH_REPLIES_OUTPUT_PROPERTIES = { */ export const POST_METADATA_OUTPUT_PROPERTIES = { id: { type: 'string', description: 'Post ID' }, + name: { type: 'string', description: 'Thing fullname (t3_xxxxx)' }, title: { type: 'string', description: 'Post title' }, author: { type: 'string', description: 'Post author' }, selftext: { type: 'string', description: 'Post text content' }, @@ -237,6 +244,7 @@ export const EDIT_DATA_OUTPUT: OutputProperty = { export interface RedditPost { id: string + name: string title: string author: string url: string @@ -248,11 +256,11 @@ export interface RedditPost { thumbnail?: string is_self: boolean subreddit: string - subreddit_name_prefixed: string } export interface RedditComment { id: string + name: string author: string body: string created_utc: number @@ -261,62 +269,72 @@ export interface RedditComment { replies: RedditComment[] } +export interface RedditMessage { + id: string + name: string + author: string + dest: string + subject: string + body: string + created_utc: number + new: boolean + was_comment: boolean + context: string + distinguished: string | null +} + export interface RedditHotPostsResponse extends ToolResponse { output: { subreddit: string posts: RedditPost[] + after: string | null + before: string | null } } -// Parameters for the generalized get_posts tool export interface RedditPostsParams { subreddit: string - sort?: 'hot' | 'new' | 'top' | 'rising' + sort?: 'hot' | 'new' | 'top' | 'rising' | 'controversial' limit?: number - time?: 'day' | 'week' | 'month' | 'year' | 'all' - // Pagination parameters + time?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all' after?: string before?: string count?: number show?: string sr_detail?: boolean + g?: string accessToken?: string } -// Response for the generalized get_posts tool export interface RedditPostsResponse extends ToolResponse { output: { subreddit: string posts: RedditPost[] + after: string | null + before: string | null } } -// Parameters for the get_comments tool export interface RedditCommentsParams { postId: string subreddit: string sort?: 'confidence' | 'top' | 'new' | 'controversial' | 'old' | 'random' | 'qa' limit?: number - // Comment-specific parameters depth?: number context?: number showedits?: boolean showmore?: boolean - showtitle?: boolean threaded?: boolean truncate?: number - // Pagination parameters - after?: string - before?: string - count?: number + comment?: string accessToken?: string } -// Response for the get_comments tool export interface RedditCommentsResponse extends ToolResponse { output: { post: { id: string + name: string title: string author: string selftext?: string @@ -328,7 +346,6 @@ export interface RedditCommentsResponse extends ToolResponse { } } -// Parameters for controversial posts export interface RedditControversialParams { subreddit: string time?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all' @@ -341,7 +358,6 @@ export interface RedditControversialParams { accessToken?: string } -// Parameters for search export interface RedditSearchParams { subreddit: string query: string @@ -353,10 +369,11 @@ export interface RedditSearchParams { count?: number show?: string restrict_sr?: boolean + type?: 'link' | 'sr' | 'user' + sr_detail?: boolean accessToken?: string } -// Parameters for submit post export interface RedditSubmitParams { subreddit: string title: string @@ -365,51 +382,81 @@ export interface RedditSubmitParams { nsfw?: boolean spoiler?: boolean send_replies?: boolean + flair_id?: string + flair_text?: string + collection_id?: string accessToken?: string } -// Parameters for vote export interface RedditVoteParams { - id: string // Thing fullname (e.g., t3_xxxxx for post, t1_xxxxx for comment) - dir: 1 | 0 | -1 // 1 = upvote, 0 = unvote, -1 = downvote + id: string + dir: 1 | 0 | -1 accessToken?: string } -// Parameters for save/unsave export interface RedditSaveParams { - id: string // Thing fullname - category?: string // Save category + id: string + category?: string accessToken?: string } -// Parameters for reply export interface RedditReplyParams { - parent_id: string // Thing fullname to reply to - text: string // Comment text in markdown + parent_id: string + text: string + return_rtjson?: boolean accessToken?: string } -// Parameters for edit export interface RedditEditParams { - thing_id: string // Thing fullname to edit - text: string // New text in markdown + thing_id: string + text: string accessToken?: string } -// Parameters for delete export interface RedditDeleteParams { - id: string // Thing fullname to delete + id: string accessToken?: string } -// Parameters for subscribe/unsubscribe export interface RedditSubscribeParams { subreddit: string action: 'sub' | 'unsub' accessToken?: string } -// Generic success response for write operations +export interface RedditGetMeParams { + accessToken?: string +} + +export interface RedditGetUserParams { + username: string + accessToken?: string +} + +export interface RedditSendMessageParams { + to: string + subject: string + text: string + from_sr?: string + accessToken?: string +} + +export interface RedditGetMessagesParams { + where?: 'inbox' | 'unread' | 'sent' | 'messages' | 'comments' | 'selfreply' | 'mentions' + limit?: number + after?: string + before?: string + mark?: boolean + count?: number + show?: string + accessToken?: string +} + +export interface RedditGetSubredditInfoParams { + subreddit: string + accessToken?: string +} + export interface RedditWriteResponse extends ToolResponse { output: { success: boolean @@ -418,8 +465,54 @@ export interface RedditWriteResponse extends ToolResponse { } } +export interface RedditUserResponse extends ToolResponse { + output: { + id: string + name: string + created_utc: number + link_karma: number + comment_karma: number + total_karma: number + is_gold: boolean + is_mod: boolean + has_verified_email: boolean + icon_img: string + } +} + +export interface RedditMessagesResponse extends ToolResponse { + output: { + messages: RedditMessage[] + after: string | null + before: string | null + } +} + +export interface RedditSubredditInfoResponse extends ToolResponse { + output: { + id: string + name: string + display_name: string + title: string + description: string + public_description: string + subscribers: number + accounts_active: number + created_utc: number + over18: boolean + lang: string + subreddit_type: string + url: string + icon_img: string | null + banner_img: string | null + } +} + export type RedditResponse = | RedditHotPostsResponse | RedditPostsResponse | RedditCommentsResponse | RedditWriteResponse + | RedditUserResponse + | RedditMessagesResponse + | RedditSubredditInfoResponse diff --git a/apps/sim/tools/reddit/utils.ts b/apps/sim/tools/reddit/utils.ts index 6a7f9b2736b..be88740d8f9 100644 --- a/apps/sim/tools/reddit/utils.ts +++ b/apps/sim/tools/reddit/utils.ts @@ -1,10 +1,19 @@ +import { validatePathSegment } from '@/lib/core/security/input-validation' + const SUBREDDIT_PREFIX = /^r\// /** * Normalizes a subreddit name by removing the 'r/' prefix if present and trimming whitespace. + * Validates the result to prevent path traversal attacks. * @param subreddit - The subreddit name to normalize * @returns The normalized subreddit name without the 'r/' prefix + * @throws Error if the subreddit name contains invalid characters */ export function normalizeSubreddit(subreddit: string): string { - return subreddit.trim().replace(SUBREDDIT_PREFIX, '') + const normalized = subreddit.trim().replace(SUBREDDIT_PREFIX, '') + const validation = validatePathSegment(normalized, { paramName: 'subreddit' }) + if (!validation.isValid) { + throw new Error(validation.error) + } + return normalized } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 8015599df29..06abac79559 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1606,11 +1606,16 @@ import { redditEditTool, redditGetCommentsTool, redditGetControversialTool, + redditGetMessagesTool, + redditGetMeTool, redditGetPostsTool, + redditGetSubredditInfoTool, + redditGetUserTool, redditHotPostsTool, redditReplyTool, redditSaveTool, redditSearchTool, + redditSendMessageTool, redditSubmitPostTool, redditSubscribeTool, redditUnsaveTool, @@ -1812,8 +1817,12 @@ import { slackListUsersTool, slackMessageReaderTool, slackMessageTool, + slackOpenViewTool, + slackPublishViewTool, + slackPushViewTool, slackRemoveReactionTool, slackUpdateMessageTool, + slackUpdateViewTool, } from '@/tools/slack' import { smsSendTool } from '@/tools/sms' import { smtpSendMailTool } from '@/tools/smtp' @@ -2619,6 +2628,10 @@ export const tools: Record = { slack_remove_reaction: slackRemoveReactionTool, slack_get_channel_info: slackGetChannelInfoTool, slack_get_user_presence: slackGetUserPresenceTool, + slack_open_view: slackOpenViewTool, + slack_update_view: slackUpdateViewTool, + slack_push_view: slackPushViewTool, + slack_publish_view: slackPublishViewTool, slack_edit_canvas: slackEditCanvasTool, slack_create_channel_canvas: slackCreateChannelCanvasTool, github_repo_info: githubRepoInfoTool, @@ -3169,6 +3182,11 @@ export const tools: Record = { reddit_edit: redditEditTool, reddit_delete: redditDeleteTool, reddit_subscribe: redditSubscribeTool, + reddit_get_me: redditGetMeTool, + reddit_get_user: redditGetUserTool, + reddit_send_message: redditSendMessageTool, + reddit_get_messages: redditGetMessagesTool, + reddit_get_subreddit_info: redditGetSubredditInfoTool, redis_get: redisGetTool, redis_set: redisSetTool, redis_delete: redisDeleteTool, diff --git a/apps/sim/tools/slack/index.ts b/apps/sim/tools/slack/index.ts index 32aba584c3f..ad8aa9ef15f 100644 --- a/apps/sim/tools/slack/index.ts +++ b/apps/sim/tools/slack/index.ts @@ -15,8 +15,12 @@ import { slackListMembersTool } from '@/tools/slack/list_members' import { slackListUsersTool } from '@/tools/slack/list_users' import { slackMessageTool } from '@/tools/slack/message' import { slackMessageReaderTool } from '@/tools/slack/message_reader' +import { slackOpenViewTool } from '@/tools/slack/open_view' +import { slackPublishViewTool } from '@/tools/slack/publish_view' +import { slackPushViewTool } from '@/tools/slack/push_view' import { slackRemoveReactionTool } from '@/tools/slack/remove_reaction' import { slackUpdateMessageTool } from '@/tools/slack/update_message' +import { slackUpdateViewTool } from '@/tools/slack/update_view' export { slackMessageTool, @@ -36,6 +40,10 @@ export { slackListUsersTool, slackGetUserTool, slackGetUserPresenceTool, + slackOpenViewTool, + slackUpdateViewTool, + slackPushViewTool, + slackPublishViewTool, slackGetMessageTool, slackGetThreadTool, } diff --git a/apps/sim/tools/slack/open_view.ts b/apps/sim/tools/slack/open_view.ts new file mode 100644 index 00000000000..9b62b51a8b8 --- /dev/null +++ b/apps/sim/tools/slack/open_view.ts @@ -0,0 +1,166 @@ +import type { SlackOpenViewParams, SlackOpenViewResponse } from '@/tools/slack/types' +import { VIEW_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackOpenViewTool: ToolConfig = { + id: 'slack_open_view', + name: 'Slack Open View', + description: + 'Open a modal view in Slack using a trigger_id from an interaction payload. Used to display forms, confirmations, and other interactive modals.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + triggerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Exchange a trigger to post to the user. Obtained from an interaction payload (e.g., slash command, button click)', + }, + interactivityPointer: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Alternative to trigger_id for posting to user', + }, + view: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'A view payload object defining the modal. Must include type ("modal"), title, and blocks array', + }, + }, + + request: { + url: 'https://slack.com/api/views.open', + method: 'POST', + headers: (params: SlackOpenViewParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackOpenViewParams) => { + const body: Record = { + view: typeof params.view === 'string' ? JSON.parse(params.view) : params.view, + } + + if (params.triggerId) { + body.trigger_id = params.triggerId.trim() + } + + if (params.interactivityPointer) { + body.interactivity_pointer = params.interactivityPointer.trim() + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'expired_trigger_id') { + throw new Error( + 'The trigger_id has expired. Trigger IDs are only valid for 3 seconds after the interaction.' + ) + } + if (data.error === 'invalid_trigger_id') { + throw new Error( + 'Invalid trigger_id. Ensure you are using a trigger_id from a valid interaction payload.' + ) + } + if (data.error === 'exchanged_trigger_id') { + throw new Error( + 'This trigger_id has already been used. Each trigger_id can only be used once.' + ) + } + if (data.error === 'view_too_large') { + throw new Error('The view payload is too large. Reduce the number of blocks or content.') + } + if (data.error === 'duplicate_external_id') { + throw new Error( + 'A view with this external_id already exists. Use a unique external_id per workspace.' + ) + } + if (data.error === 'invalid_arguments') { + const messages = data.response_metadata?.messages ?? [] + throw new Error( + `Invalid view arguments: ${messages.length > 0 ? messages.join(', ') : data.error}` + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes.' + ) + } + if ( + data.error === 'invalid_auth' || + data.error === 'not_authed' || + data.error === 'token_expired' + ) { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to open view in Slack') + } + + const view = data.view + + return { + success: true, + output: { + view: { + id: view.id, + team_id: view.team_id ?? null, + type: view.type, + title: view.title ?? null, + submit: view.submit ?? null, + close: view.close ?? null, + blocks: view.blocks ?? [], + private_metadata: view.private_metadata ?? null, + callback_id: view.callback_id ?? null, + external_id: view.external_id ?? null, + state: view.state ?? null, + hash: view.hash ?? null, + clear_on_close: view.clear_on_close ?? false, + notify_on_close: view.notify_on_close ?? false, + root_view_id: view.root_view_id ?? null, + previous_view_id: view.previous_view_id ?? null, + app_id: view.app_id ?? null, + bot_id: view.bot_id ?? null, + }, + }, + } + }, + + outputs: { + view: { + type: 'object', + description: 'The opened modal view object', + properties: VIEW_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/slack/publish_view.ts b/apps/sim/tools/slack/publish_view.ts new file mode 100644 index 00000000000..c9f5a85dffc --- /dev/null +++ b/apps/sim/tools/slack/publish_view.ts @@ -0,0 +1,163 @@ +import type { SlackPublishViewParams, SlackPublishViewResponse } from '@/tools/slack/types' +import { VIEW_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackPublishViewTool: ToolConfig = { + id: 'slack_publish_view', + name: 'Slack Publish View', + description: + "Publish a static view to a user's Home tab in Slack. Used to create or update the app's Home tab experience.", + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The user ID to publish the Home tab view to (e.g., U0BPQUNTA)', + }, + hash: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'View state hash to protect against race conditions. Obtained from a previous views response', + }, + view: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'A view payload object defining the Home tab. Must include type ("home") and blocks array', + }, + }, + + request: { + url: 'https://slack.com/api/views.publish', + method: 'POST', + headers: (params: SlackPublishViewParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackPublishViewParams) => { + const body: Record = { + user_id: params.userId.trim(), + view: typeof params.view === 'string' ? JSON.parse(params.view) : params.view, + } + + if (params.hash) { + body.hash = params.hash.trim() + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'not_found') { + throw new Error('User not found. Please check the user ID and try again.') + } + if (data.error === 'not_enabled') { + throw new Error( + 'The Home tab is not enabled for this app. Enable it in your app configuration.' + ) + } + if (data.error === 'hash_conflict') { + throw new Error( + 'The view has been modified since the hash was generated. Retrieve the latest view and try again.' + ) + } + if (data.error === 'view_too_large') { + throw new Error( + 'The view payload is too large (max 250kb). Reduce the number of blocks or content.' + ) + } + if (data.error === 'duplicate_external_id') { + throw new Error( + 'A view with this external_id already exists. Use a unique external_id per workspace.' + ) + } + if (data.error === 'invalid_arguments') { + const messages = data.response_metadata?.messages ?? [] + throw new Error( + `Invalid view arguments: ${messages.length > 0 ? messages.join(', ') : data.error}` + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes.' + ) + } + if ( + data.error === 'invalid_auth' || + data.error === 'not_authed' || + data.error === 'token_expired' + ) { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to publish view in Slack') + } + + const view = data.view + + return { + success: true, + output: { + view: { + id: view.id, + team_id: view.team_id ?? null, + type: view.type, + title: view.title ?? null, + submit: view.submit ?? null, + close: view.close ?? null, + blocks: view.blocks ?? [], + private_metadata: view.private_metadata ?? null, + callback_id: view.callback_id ?? null, + external_id: view.external_id ?? null, + state: view.state ?? null, + hash: view.hash ?? null, + clear_on_close: view.clear_on_close ?? false, + notify_on_close: view.notify_on_close ?? false, + root_view_id: view.root_view_id ?? null, + previous_view_id: view.previous_view_id ?? null, + app_id: view.app_id ?? null, + bot_id: view.bot_id ?? null, + }, + }, + } + }, + + outputs: { + view: { + type: 'object', + description: 'The published Home tab view object', + properties: VIEW_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/slack/push_view.ts b/apps/sim/tools/slack/push_view.ts new file mode 100644 index 00000000000..67271faeeeb --- /dev/null +++ b/apps/sim/tools/slack/push_view.ts @@ -0,0 +1,173 @@ +import type { SlackPushViewParams, SlackPushViewResponse } from '@/tools/slack/types' +import { VIEW_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackPushViewTool: ToolConfig = { + id: 'slack_push_view', + name: 'Slack Push View', + description: + 'Push a new view onto an existing modal stack in Slack. Limited to 2 additional views after the initial modal is opened.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + triggerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Exchange a trigger to post to the user. Obtained from an interaction payload (e.g., button click within an existing modal)', + }, + interactivityPointer: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Alternative to trigger_id for posting to user', + }, + view: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'A view payload object defining the modal to push. Must include type ("modal"), title, and blocks array', + }, + }, + + request: { + url: 'https://slack.com/api/views.push', + method: 'POST', + headers: (params: SlackPushViewParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackPushViewParams) => { + const body: Record = { + view: typeof params.view === 'string' ? JSON.parse(params.view) : params.view, + } + + if (params.triggerId) { + body.trigger_id = params.triggerId.trim() + } + + if (params.interactivityPointer) { + body.interactivity_pointer = params.interactivityPointer.trim() + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'expired_trigger_id') { + throw new Error( + 'The trigger_id has expired. Trigger IDs are only valid for 3 seconds after the interaction.' + ) + } + if (data.error === 'invalid_trigger_id') { + throw new Error( + 'Invalid trigger_id. Ensure you are using a trigger_id from a valid interaction payload.' + ) + } + if (data.error === 'exchanged_trigger_id') { + throw new Error( + 'This trigger_id has already been used. Each trigger_id can only be used once.' + ) + } + if (data.error === 'push_limit_reached') { + throw new Error( + 'Cannot push more views. After a modal is opened, only 2 additional views can be pushed onto the stack.' + ) + } + if (data.error === 'view_too_large') { + throw new Error( + 'The view payload is too large (max 250kb). Reduce the number of blocks or content.' + ) + } + if (data.error === 'duplicate_external_id') { + throw new Error( + 'A view with this external_id already exists. Use a unique external_id per workspace.' + ) + } + if (data.error === 'invalid_arguments') { + const messages = data.response_metadata?.messages ?? [] + throw new Error( + `Invalid view arguments: ${messages.length > 0 ? messages.join(', ') : data.error}` + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes.' + ) + } + if ( + data.error === 'invalid_auth' || + data.error === 'not_authed' || + data.error === 'token_expired' + ) { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to push view in Slack') + } + + const view = data.view + + return { + success: true, + output: { + view: { + id: view.id, + team_id: view.team_id ?? null, + type: view.type, + title: view.title ?? null, + submit: view.submit ?? null, + close: view.close ?? null, + blocks: view.blocks ?? [], + private_metadata: view.private_metadata ?? null, + callback_id: view.callback_id ?? null, + external_id: view.external_id ?? null, + state: view.state ?? null, + hash: view.hash ?? null, + clear_on_close: view.clear_on_close ?? false, + notify_on_close: view.notify_on_close ?? false, + root_view_id: view.root_view_id ?? null, + previous_view_id: view.previous_view_id ?? null, + app_id: view.app_id ?? null, + bot_id: view.bot_id ?? null, + }, + }, + } + }, + + outputs: { + view: { + type: 'object', + description: 'The pushed modal view object', + properties: VIEW_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index e41a99af21f..d16b3a27e67 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -478,6 +478,90 @@ export const CANVAS_OUTPUT_PROPERTIES = { title: { type: 'string', description: 'Canvas title' }, } as const satisfies Record +/** + * Output definition for modal view objects + * Based on Slack views.open response structure + */ +export const VIEW_OUTPUT_PROPERTIES = { + id: { type: 'string', description: 'Unique view identifier' }, + team_id: { type: 'string', description: 'Workspace/team ID', optional: true }, + type: { type: 'string', description: 'View type (e.g., "modal")' }, + title: { + type: 'json', + description: 'Plain text title object with type and text fields', + optional: true, + properties: { + type: { type: 'string', description: 'Text object type (plain_text)' }, + text: { type: 'string', description: 'Title text content' }, + }, + }, + submit: { + type: 'json', + description: 'Plain text submit button object', + optional: true, + properties: { + type: { type: 'string', description: 'Text object type (plain_text)' }, + text: { type: 'string', description: 'Submit button text' }, + }, + }, + close: { + type: 'json', + description: 'Plain text close button object', + optional: true, + properties: { + type: { type: 'string', description: 'Text object type (plain_text)' }, + text: { type: 'string', description: 'Close button text' }, + }, + }, + blocks: { + type: 'array', + description: 'Block Kit blocks in the view', + items: { + type: 'object', + properties: BLOCK_OUTPUT_PROPERTIES, + }, + }, + private_metadata: { + type: 'string', + description: 'Private metadata string passed with the view', + optional: true, + }, + callback_id: { type: 'string', description: 'Custom identifier for the view', optional: true }, + external_id: { + type: 'string', + description: 'Custom external identifier (max 255 chars, unique per workspace)', + optional: true, + }, + state: { + type: 'json', + description: 'Current state of the view with input values', + optional: true, + }, + hash: { type: 'string', description: 'View version hash for updates', optional: true }, + clear_on_close: { + type: 'boolean', + description: 'Whether to clear all views in the stack when this view is closed', + optional: true, + }, + notify_on_close: { + type: 'boolean', + description: 'Whether to send a view_closed event when this view is closed', + optional: true, + }, + root_view_id: { + type: 'string', + description: 'ID of the root view in the view stack', + optional: true, + }, + previous_view_id: { + type: 'string', + description: 'ID of the previous view in the view stack', + optional: true, + }, + app_id: { type: 'string', description: 'Application identifier', optional: true }, + bot_id: { type: 'string', description: 'Bot identifier', optional: true }, +} as const satisfies Record + /** * File download output properties */ @@ -629,6 +713,31 @@ export interface SlackCreateChannelCanvasParams extends SlackBaseParams { content?: string } +export interface SlackOpenViewParams extends SlackBaseParams { + triggerId: string + interactivityPointer?: string + view: object | string +} + +export interface SlackUpdateViewParams extends SlackBaseParams { + viewId?: string + externalId?: string + hash?: string + view: object | string +} + +export interface SlackPushViewParams extends SlackBaseParams { + triggerId: string + interactivityPointer?: string + view: object | string +} + +export interface SlackPublishViewParams extends SlackBaseParams { + userId: string + hash?: string + view: object | string +} + export interface SlackMessageResponse extends ToolResponse { output: { // Legacy properties for backward compatibility @@ -933,6 +1042,51 @@ export interface SlackCreateChannelCanvasResponse extends ToolResponse { } } +export interface SlackView { + id: string + team_id?: string | null + type: string + title?: { type: string; text: string } | null + submit?: { type: string; text: string } | null + close?: { type: string; text: string } | null + blocks: SlackBlock[] + private_metadata?: string | null + callback_id?: string | null + external_id?: string | null + state?: Record | null + hash?: string | null + clear_on_close?: boolean + notify_on_close?: boolean + root_view_id?: string | null + previous_view_id?: string | null + app_id?: string | null + bot_id?: string | null +} + +export interface SlackOpenViewResponse extends ToolResponse { + output: { + view: SlackView + } +} + +export interface SlackUpdateViewResponse extends ToolResponse { + output: { + view: SlackView + } +} + +export interface SlackPushViewResponse extends ToolResponse { + output: { + view: SlackView + } +} + +export interface SlackPublishViewResponse extends ToolResponse { + output: { + view: SlackView + } +} + export type SlackResponse = | SlackCanvasResponse | SlackMessageReaderResponse @@ -953,3 +1107,7 @@ export type SlackResponse = | SlackGetUserPresenceResponse | SlackEditCanvasResponse | SlackCreateChannelCanvasResponse + | SlackOpenViewResponse + | SlackUpdateViewResponse + | SlackPushViewResponse + | SlackPublishViewResponse diff --git a/apps/sim/tools/slack/update_view.ts b/apps/sim/tools/slack/update_view.ts new file mode 100644 index 00000000000..727344e74d3 --- /dev/null +++ b/apps/sim/tools/slack/update_view.ts @@ -0,0 +1,175 @@ +import type { SlackUpdateViewParams, SlackUpdateViewResponse } from '@/tools/slack/types' +import { VIEW_OUTPUT_PROPERTIES } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackUpdateViewTool: ToolConfig = { + id: 'slack_update_view', + name: 'Slack Update View', + description: + 'Update an existing modal view in Slack. Identify the view by view_id or external_id, and provide the updated view payload.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + viewId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Unique identifier of the view to update. Either viewId or externalId is required', + }, + externalId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Developer-set unique identifier of the view to update (max 255 chars). Either viewId or externalId is required', + }, + hash: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'View state hash to protect against race conditions. Obtained from a previous views response', + }, + view: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'A view payload object defining the updated modal. Must include type ("modal"), title, and blocks array. Use identical block_id and action_id values to preserve input data', + }, + }, + + request: { + url: 'https://slack.com/api/views.update', + method: 'POST', + headers: (params: SlackUpdateViewParams) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackUpdateViewParams) => { + const body: Record = { + view: typeof params.view === 'string' ? JSON.parse(params.view) : params.view, + } + + if (params.viewId) { + body.view_id = params.viewId.trim() + } + + if (params.externalId) { + body.external_id = params.externalId.trim() + } + + if (params.hash) { + body.hash = params.hash.trim() + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + if (data.error === 'not_found') { + throw new Error( + 'View not found. The provided view_id or external_id does not match an existing view.' + ) + } + if (data.error === 'hash_conflict') { + throw new Error( + 'The view has been modified since the hash was generated. Retrieve the latest view and try again.' + ) + } + if (data.error === 'view_too_large') { + throw new Error( + 'The view payload is too large (max 250kb). Reduce the number of blocks or content.' + ) + } + if (data.error === 'duplicate_external_id') { + throw new Error( + 'A view with this external_id already exists. Use a unique external_id per workspace.' + ) + } + if (data.error === 'invalid_arguments') { + const messages = data.response_metadata?.messages ?? [] + throw new Error( + `Invalid view arguments: ${messages.length > 0 ? messages.join(', ') : data.error}` + ) + } + if (data.error === 'missing_scope') { + throw new Error( + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes.' + ) + } + if ( + data.error === 'invalid_auth' || + data.error === 'not_authed' || + data.error === 'token_expired' + ) { + throw new Error('Invalid authentication. Please check your Slack credentials.') + } + throw new Error(data.error || 'Failed to update view in Slack') + } + + const view = data.view + + return { + success: true, + output: { + view: { + id: view.id, + team_id: view.team_id ?? null, + type: view.type, + title: view.title ?? null, + submit: view.submit ?? null, + close: view.close ?? null, + blocks: view.blocks ?? [], + private_metadata: view.private_metadata ?? null, + callback_id: view.callback_id ?? null, + external_id: view.external_id ?? null, + state: view.state ?? null, + hash: view.hash ?? null, + clear_on_close: view.clear_on_close ?? false, + notify_on_close: view.notify_on_close ?? false, + root_view_id: view.root_view_id ?? null, + previous_view_id: view.previous_view_id ?? null, + app_id: view.app_id ?? null, + bot_id: view.bot_id ?? null, + }, + }, + } + }, + + outputs: { + view: { + type: 'object', + description: 'The updated modal view object', + properties: VIEW_OUTPUT_PROPERTIES, + }, + }, +}