feat(reddit): add 5 new tools, fix bugs, and audit all endpoints against API docs (#3434)

* feat(reddit): add 5 new tools, fix bugs, and audit all endpoints against API docs

* fix(reddit): add optional chaining, pagination wiring, and trim safety

- Add optional chaining on children?.[0] in get_posts, get_controversial,
  search, and get_comments to prevent TypeError on unexpected API responses
- Wire after/before pagination params to get_messages block operation
- Use ?? instead of || for get_comments limit to handle 0 correctly
- Add .trim() on postId in get_comments URL path

* chore(reddit): remove unused output property constants from types.ts

* fix(reddit): add HTTP error handling to GET tools

Add !response.ok guards to get_me, get_user, get_subreddit_info,
and get_messages to return success: false on non-2xx responses
instead of silently returning empty data with success: true.

* fix(reddit): add input validation and HTTP error guards

- Add validateEnum/validatePathSegment to prevent URL path traversal
- Add !response.ok guards to send_message and reply tools
- Centralize subreddit validation in normalizeSubreddit
This commit is contained in:
Waleed
2026-03-05 20:07:29 -08:00
committed by GitHub
parent 4f45f705a5
commit 2722f0efbf
20 changed files with 1670 additions and 436 deletions
+171 -7
View File
@@ -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 |
File diff suppressed because it is too large Load Diff
@@ -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', () => {
+1 -3
View File
@@ -46,9 +46,7 @@ export const deleteTool: ToolConfig<RedditDeleteParams, RedditWriteResponse> = {
id: params.id,
})
return {
body: formData.toString(),
}
return formData.toString() as unknown as Record<string, any>
},
},
+27 -37
View File
@@ -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<RedditCommentsParams, RedditCommentsRes
visibility: 'user-or-llm',
description: 'Include "load more comments" elements in the response',
},
showtitle: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Include submission title in the response',
},
threaded: {
type: 'boolean',
required: false,
@@ -87,23 +82,11 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
visibility: 'user-or-llm',
description: 'Integer to truncate comment depth',
},
after: {
comment: {
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)',
},
count: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'A count of items already seen in the listing (used for numbering)',
description: 'ID36 of a comment to focus on (returns that comment thread)',
},
},
@@ -111,7 +94,7 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
url: (params: RedditCommentsParams) => {
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<RedditCommentsParams, RedditCommentsRes
urlParams.append('context', Number(params.context).toString())
if (params.showedits !== undefined) urlParams.append('showedits', params.showedits.toString())
if (params.showmore !== undefined) urlParams.append('showmore', params.showmore.toString())
if (params.showtitle !== undefined) urlParams.append('showtitle', params.showtitle.toString())
if (params.threaded !== undefined) urlParams.append('threaded', params.threaded.toString())
if (params.truncate !== undefined)
urlParams.append('truncate', Number(params.truncate).toString())
// Add pagination parameters if provided
if (params.after) urlParams.append('after', params.after)
if (params.before) urlParams.append('before', params.before)
if (params.count !== undefined) urlParams.append('count', Number(params.count).toString())
if (params.comment) urlParams.append('comment', params.comment)
// Validate postId to prevent path traversal
const postId = params.postId.trim()
const postIdValidation = validatePathSegment(postId, { paramName: 'postId' })
if (!postIdValidation.isValid) {
throw new Error(postIdValidation.error)
}
// Build URL using OAuth endpoint
return `https://oauth.reddit.com/r/${subreddit}/comments/${params.postId}?${urlParams.toString()}`
return `https://oauth.reddit.com/r/${subreddit}/comments/${postId}?${urlParams.toString()}`
},
method: 'GET',
headers: (params: RedditCommentsParams) => {
@@ -157,7 +143,7 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
const data = await response.json()
// Extract post data (first element in the array)
const postData = data[0]?.data?.children[0]?.data || {}
const postData = data[0]?.data?.children?.[0]?.data || {}
// Extract and transform comments (second element in the array)
const commentsData = data[1]?.data?.children || []
@@ -179,11 +165,12 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
: []
return {
id: commentData.id || '',
id: commentData.id ?? '',
name: commentData.name ?? '',
author: commentData.author || '[deleted]',
body: commentData.body || '',
created_utc: commentData.created_utc || 0,
score: commentData.score || 0,
body: commentData.body ?? '',
created_utc: commentData.created_utc ?? 0,
score: commentData.score ?? 0,
permalink: commentData.permalink
? `https://www.reddit.com${commentData.permalink}`
: '',
@@ -199,12 +186,13 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
success: true,
output: {
post: {
id: postData.id || '',
title: postData.title || '',
id: postData.id ?? '',
name: postData.name ?? '',
title: postData.title ?? '',
author: postData.author || '[deleted]',
selftext: postData.selftext || '',
created_utc: postData.created_utc || 0,
score: postData.score || 0,
selftext: postData.selftext ?? '',
created_utc: postData.created_utc ?? 0,
score: postData.score ?? 0,
permalink: postData.permalink ? `https://www.reddit.com${postData.permalink}` : '',
},
comments: comments,
@@ -218,6 +206,7 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
description: 'Post information including ID, title, author, content, and metadata',
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' },
@@ -233,6 +222,7 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
type: 'object',
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' },
+25 -11
View File
@@ -74,7 +74,7 @@ export const getControversialTool: ToolConfig<RedditControversialParams, RedditP
request: {
url: (params: RedditControversialParams) => {
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<RedditControversialParams, RedditP
// 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,
}
}) || []
@@ -142,6 +143,8 @@ export const getControversialTool: ToolConfig<RedditControversialParams, RedditP
output: {
subreddit: subredditName,
posts,
after: data.data?.after ?? null,
before: data.data?.before ?? null,
},
}
},
@@ -159,6 +162,7 @@ export const getControversialTool: ToolConfig<RedditControversialParams, RedditP
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' },
@@ -173,5 +177,15 @@ export const getControversialTool: ToolConfig<RedditControversialParams, RedditP
},
},
},
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,
},
},
}
+90
View File
@@ -0,0 +1,90 @@
import type { RedditGetMeParams, RedditUserResponse } from '@/tools/reddit/types'
import type { ToolConfig } from '@/tools/types'
export const getMeTool: ToolConfig<RedditGetMeParams, RedditUserResponse> = {
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' },
},
}
+188
View File
@@ -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<RedditGetMessagesParams, RedditMessagesResponse> = {
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,
},
},
}
+48 -15
View File
@@ -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<RedditPostsParams, RedditPostsResponse> = {
id: 'reddit_get_posts',
name: 'Get Reddit Posts',
@@ -30,7 +33,8 @@ export const getPostsTool: ToolConfig<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
// 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<RedditPostsParams, RedditPostsResponse> =
output: {
subreddit: subredditName,
posts,
after: data.data?.after ?? null,
before: data.data?.before ?? null,
},
}
},
@@ -170,6 +192,7 @@ export const getPostsTool: ToolConfig<RedditPostsParams, RedditPostsResponse> =
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<RedditPostsParams, RedditPostsResponse> =
},
},
},
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,
},
},
}
+126
View File
@@ -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 },
},
}
+106
View File
@@ -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<RedditGetUserParams, RedditUserResponse> = {
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' },
},
}
+34 -21
View File
@@ -43,7 +43,7 @@ export const hotPostsTool: ToolConfig<HotPostsParams, RedditHotPostsResponse> =
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<HotPostsParams, RedditHotPostsResponse> =
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<HotPostsParams, RedditHotPostsResponse> =
output: {
subreddit,
posts,
after: data.data?.after ?? null,
before: data.data?.before ?? null,
},
}
},
@@ -112,6 +115,7 @@ export const hotPostsTool: ToolConfig<HotPostsParams, RedditHotPostsResponse> =
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<HotPostsParams, RedditHotPostsResponse> =
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,
},
},
}
+10
View File
@@ -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
+20
View File
@@ -32,6 +32,12 @@ export const replyTool: ToolConfig<RedditReplyParams, RedditWriteResponse> = {
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<RedditReplyParams, RedditWriteResponse> = {
api_type: 'json',
})
if (params.return_rtjson !== undefined)
formData.append('return_rtjson', params.return_rtjson.toString())
return formData.toString() as unknown as Record<string, any>
},
},
@@ -62,6 +71,17 @@ export const replyTool: ToolConfig<RedditReplyParams, RedditWriteResponse> = {
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(', ')
+40 -11
View File
@@ -83,13 +83,26 @@ export const searchTool: ToolConfig<RedditSearchParams, RedditPostsResponse> = {
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<RedditSearchParams, RedditPostsResponse> = {
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<RedditSearchParams, RedditPostsResponse> = {
// 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<RedditSearchParams, RedditPostsResponse> = {
output: {
subreddit: subredditName,
posts,
after: data.data?.after ?? null,
before: data.data?.before ?? null,
},
}
},
@@ -177,6 +195,7 @@ export const searchTool: ToolConfig<RedditSearchParams, RedditPostsResponse> = {
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<RedditSearchParams, RedditPostsResponse> = {
},
},
},
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,
},
},
}
+122
View File
@@ -0,0 +1,122 @@
import type { RedditSendMessageParams, RedditWriteResponse } from '@/tools/reddit/types'
import type { ToolConfig } from '@/tools/types'
export const sendMessageTool: ToolConfig<RedditSendMessageParams, RedditWriteResponse> = {
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<string, any>
},
},
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',
},
},
}
+24 -1
View File
@@ -64,6 +64,24 @@ export const submitPostTool: ToolConfig<RedditSubmitParams, RedditWriteResponse>
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<RedditSubmitParams, RedditWriteResponse>
// 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<RedditSubmitParams, RedditWriteResponse>
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 ?? ''),
},
},
}
+126 -33
View File
@@ -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
+10 -1
View File
@@ -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
}
+10
View File
@@ -1606,11 +1606,16 @@ import {
redditEditTool,
redditGetCommentsTool,
redditGetControversialTool,
redditGetMessagesTool,
redditGetMeTool,
redditGetPostsTool,
redditGetSubredditInfoTool,
redditGetUserTool,
redditHotPostsTool,
redditReplyTool,
redditSaveTool,
redditSearchTool,
redditSendMessageTool,
redditSubmitPostTool,
redditSubscribeTool,
redditUnsaveTool,
@@ -3169,6 +3174,11 @@ export const tools: Record<string, ToolConfig> = {
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,