mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 |
|
||||
|
||||
|
||||
|
||||
+480
-284
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', () => {
|
||||
|
||||
@@ -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>
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(', ')
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user