diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..c0a9c1a 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -84,6 +84,14 @@ export interface NodeCredentials { refreshToken: string expiresAt: number } + /** Reddit OAuth2 credentials */ + reddit?: { + clientId?: string + clientSecret?: string + accessToken?: string + refreshToken?: string + expiresAt?: number + } } /** diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 97e62dc..6b6edd8 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -80,6 +80,22 @@ export { redditMonitorNode, RedditMonitorInputSchema, RedditMonitorOutputSchema, + redditCredential, + redditCreatePostNode, + RedditCreatePostInputSchema, + RedditCreatePostOutputSchema, + redditCreateCommentNode, + RedditCreateCommentInputSchema, + RedditCreateCommentOutputSchema, + redditReplyToCommentNode, + RedditReplyToCommentInputSchema, + RedditReplyToCommentOutputSchema, + redditSearchPostsNode, + RedditSearchPostsInputSchema, + RedditSearchPostsOutputSchema, + redditGetPostCommentsNode, + RedditGetPostCommentsInputSchema, + RedditGetPostCommentsOutputSchema, twitterMonitorNode, TwitterMonitorInputSchema, TwitterMonitorOutputSchema, @@ -202,6 +218,16 @@ export type { RedditMonitorInput, RedditMonitorOutput, RedditPost, + RedditCreatePostInput, + RedditCreatePostOutput, + RedditCreateCommentInput, + RedditCreateCommentOutput, + RedditReplyToCommentInput, + RedditReplyToCommentOutput, + RedditSearchPostsInput, + RedditSearchPostsOutput, + RedditGetPostCommentsInput, + RedditGetPostCommentsOutput, TwitterMonitorInput, TwitterMonitorOutput, TwitterPost, @@ -315,6 +341,11 @@ import { mapNode, filterNode, sortNode } from './transform/index.js' import { httpRequestNode, breadNode } from './examples/index.js' import { redditMonitorNode, + redditCreatePostNode, + redditCreateCommentNode, + redditReplyToCommentNode, + redditSearchPostsNode, + redditGetPostCommentsNode, twitterMonitorNode, twitterCreateTweetNode, twitterDeleteTweetNode, @@ -373,6 +404,11 @@ export const builtInNodes = [ breadNode, // Integrations redditMonitorNode, + redditCreatePostNode, + redditCreateCommentNode, + redditReplyToCommentNode, + redditSearchPostsNode, + redditGetPostCommentsNode, twitterMonitorNode, twitterCreateTweetNode, twitterDeleteTweetNode, diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..2b7c502 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -13,6 +13,32 @@ export { type TwitterMonitorOutput, type TwitterPost, twitterCredential, + redditCredential, + redditCreatePostNode, + RedditCreatePostInputSchema, + RedditCreatePostOutputSchema, + type RedditCreatePostInput, + type RedditCreatePostOutput, + redditCreateCommentNode, + RedditCreateCommentInputSchema, + RedditCreateCommentOutputSchema, + type RedditCreateCommentInput, + type RedditCreateCommentOutput, + redditReplyToCommentNode, + RedditReplyToCommentInputSchema, + RedditReplyToCommentOutputSchema, + type RedditReplyToCommentInput, + type RedditReplyToCommentOutput, + redditSearchPostsNode, + RedditSearchPostsInputSchema, + RedditSearchPostsOutputSchema, + type RedditSearchPostsInput, + type RedditSearchPostsOutput, + redditGetPostCommentsNode, + RedditGetPostCommentsInputSchema, + RedditGetPostCommentsOutputSchema, + type RedditGetPostCommentsInput, + type RedditGetPostCommentsOutput, twitterCreateTweetNode, TwitterCreateTweetInputSchema, TwitterCreateTweetOutputSchema, diff --git a/packages/nodes/src/integrations/social/credentials.ts b/packages/nodes/src/integrations/social/credentials.ts index 255e640..60f9409 100644 --- a/packages/nodes/src/integrations/social/credentials.ts +++ b/packages/nodes/src/integrations/social/credentials.ts @@ -1,6 +1,24 @@ import { z } from 'zod'; import { defineOAuth2Credential } from '@jam-nodes/core'; +export const redditCredential = defineOAuth2Credential({ + name: 'reddit', + displayName: 'Reddit OAuth2', + documentationUrl: 'https://www.reddit.com/dev/api/', + config: { + authorizationUrl: 'https://www.reddit.com/api/v1/authorize', + tokenUrl: 'https://www.reddit.com/api/v1/access_token', + scopes: ['identity', 'submit', 'read', 'history', 'mysubreddits'], + }, + schema: z.object({ + clientId: z.string(), + clientSecret: z.string(), + accessToken: z.string(), + refreshToken: z.string().optional(), + expiresAt: z.number().optional(), + }), +}); + export const twitterCredential = defineOAuth2Credential({ pkce: true, name: 'twitter', diff --git a/packages/nodes/src/integrations/social/index.ts b/packages/nodes/src/integrations/social/index.ts index 48c677f..4d44a6b 100644 --- a/packages/nodes/src/integrations/social/index.ts +++ b/packages/nodes/src/integrations/social/index.ts @@ -18,8 +18,49 @@ export { export { twitterCredential, + redditCredential, } from './credentials.js'; +export { + redditCreatePostNode, + RedditCreatePostInputSchema, + RedditCreatePostOutputSchema, + type RedditCreatePostInput, + type RedditCreatePostOutput, +} from './reddit-create-post.js'; + +export { + redditCreateCommentNode, + RedditCreateCommentInputSchema, + RedditCreateCommentOutputSchema, + type RedditCreateCommentInput, + type RedditCreateCommentOutput, +} from './reddit-create-comment.js'; + +export { + redditReplyToCommentNode, + RedditReplyToCommentInputSchema, + RedditReplyToCommentOutputSchema, + type RedditReplyToCommentInput, + type RedditReplyToCommentOutput, +} from './reddit-reply-to-comment.js'; + +export { + redditSearchPostsNode, + RedditSearchPostsInputSchema, + RedditSearchPostsOutputSchema, + type RedditSearchPostsInput, + type RedditSearchPostsOutput, +} from './reddit-search-posts.js'; + +export { + redditGetPostCommentsNode, + RedditGetPostCommentsInputSchema, + RedditGetPostCommentsOutputSchema, + type RedditGetPostCommentsInput, + type RedditGetPostCommentsOutput, +} from './reddit-get-post-comments.js'; + export { twitterCreateTweetNode, TwitterCreateTweetInputSchema, diff --git a/packages/nodes/src/integrations/social/reddit-client.ts b/packages/nodes/src/integrations/social/reddit-client.ts new file mode 100644 index 0000000..b40eef2 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-client.ts @@ -0,0 +1,64 @@ +import type { NodeExecutionContext } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +const REDDIT_OAUTH_BASE_URL = 'https://oauth.reddit.com'; +const REDDIT_USER_AGENT = 'jam-nodes/1.0'; + +export function getRedditAccessToken(context: NodeExecutionContext): string | null { + const creds = (context.credentials?.reddit || {}) as Record; + return creds['accessToken'] || null; +} + +export async function redditRequest( + context: NodeExecutionContext, + path: string, + init: RequestInit = {} +): Promise { + const accessToken = getRedditAccessToken(context); + if (!accessToken) { + throw new Error( + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.' + ); + } + + const fullUrl = `${REDDIT_OAUTH_BASE_URL}${path}`; + + const response = await fetchWithRetry( + fullUrl, + { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': REDDIT_USER_AGENT, + ...(init.headers || {}), + }, + }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Reddit API error: ${response.status} - ${errorText}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +export function formatRedditErrors(errors: unknown): string { + if (!Array.isArray(errors) || errors.length === 0) { + return 'Unknown Reddit API error'; + } + return errors + .map((err) => { + if (Array.isArray(err)) { + return err.filter((part) => typeof part === 'string').join(': '); + } + if (typeof err === 'string') return err; + return JSON.stringify(err); + }) + .join('; '); +} diff --git a/packages/nodes/src/integrations/social/reddit-create-comment.ts b/packages/nodes/src/integrations/social/reddit-create-comment.ts new file mode 100644 index 0000000..791f398 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-create-comment.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { getRedditAccessToken, redditRequest, formatRedditErrors } from './reddit-client.js'; + +interface RedditCommentResponse { + json?: { + errors?: unknown[]; + data?: { + things?: Array<{ + kind?: string; + data?: { + id?: string; + name?: string; + permalink?: string; + }; + }>; + }; + }; +} + +export const RedditCreateCommentInputSchema = z.object({ + postId: z.string().min(1), + text: z.string().min(1), +}); + +export type RedditCreateCommentInput = z.infer; + +export const RedditCreateCommentOutputSchema = z.object({ + commentId: z.string(), + commentName: z.string(), + permalink: z.string(), +}); + +export type RedditCreateCommentOutput = z.infer; + +function normalizePostThingId(postId: string): string { + if (postId.startsWith('t3_') || postId.startsWith('t1_')) { + return postId; + } + return `t3_${postId}`; +} + +export const redditCreateCommentNode = defineNode({ + type: 'reddit_create_comment', + name: 'Reddit Create Comment', + description: 'Post a top-level comment on a Reddit post', + category: 'integration', + inputSchema: RedditCreateCommentInputSchema, + outputSchema: RedditCreateCommentOutputSchema, + estimatedDuration: 6, + capabilities: { + supportsRerun: true, + }, + executor: async (input, context) => { + try { + if (!getRedditAccessToken(context)) { + return { + success: false, + error: + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.', + }; + } + + const body = new URLSearchParams(); + body.set('api_type', 'json'); + body.set('thing_id', normalizePostThingId(input.postId)); + body.set('text', input.text); + + const response = await redditRequest(context, '/api/comment', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + const errors = response.json?.errors; + if (Array.isArray(errors) && errors.length > 0) { + return { + success: false, + error: `Reddit API error: ${formatRedditErrors(errors)}`, + }; + } + + const thing = response.json?.data?.things?.[0]; + const commentId = thing?.data?.id; + const commentName = thing?.data?.name; + const rawPermalink = thing?.data?.permalink; + + if (!commentId || !commentName) { + return { + success: false, + error: 'Reddit API returned an invalid create comment response.', + }; + } + + const permalink = rawPermalink ? `https://www.reddit.com${rawPermalink}` : ''; + + return { + success: true, + output: { + commentId, + commentName, + permalink, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create Reddit comment', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/social/reddit-create-post.ts b/packages/nodes/src/integrations/social/reddit-create-post.ts new file mode 100644 index 0000000..d0a4f91 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-create-post.ts @@ -0,0 +1,142 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { getRedditAccessToken, redditRequest, formatRedditErrors } from './reddit-client.js'; + +interface RedditSubmitResponse { + json?: { + errors?: unknown[]; + data?: { + id?: string; + name?: string; + url?: string; + }; + }; +} + +export const RedditCreatePostInputSchema = z + .object({ + subreddit: z.string().min(1), + title: z.string().min(1).max(300), + kind: z.enum(['self', 'link', 'image']), + text: z.string().optional(), + url: z.string().url().optional(), + flair: z.string().optional(), + sendReplies: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + if (data.kind === 'self' && !data.text) { + ctx.addIssue({ + code: 'custom', + path: ['text'], + message: 'text is required when kind is "self"', + }); + } + if ((data.kind === 'link' || data.kind === 'image') && !data.url) { + ctx.addIssue({ + code: 'custom', + path: ['url'], + message: `url is required when kind is "${data.kind}"`, + }); + } + }); + +export type RedditCreatePostInput = z.infer; + +export const RedditCreatePostOutputSchema = z.object({ + postId: z.string(), + postName: z.string(), + url: z.string(), + permalink: z.string(), + title: z.string(), + subreddit: z.string(), +}); + +export type RedditCreatePostOutput = z.infer; + +export const redditCreatePostNode = defineNode({ + type: 'reddit_create_post', + name: 'Reddit Create Post', + description: 'Create a new post (self, link, or image) in a subreddit', + category: 'integration', + inputSchema: RedditCreatePostInputSchema, + outputSchema: RedditCreatePostOutputSchema, + estimatedDuration: 8, + capabilities: { + supportsRerun: true, + }, + executor: async (input, context) => { + try { + if (!getRedditAccessToken(context)) { + return { + success: false, + error: + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.', + }; + } + + const body = new URLSearchParams(); + body.set('api_type', 'json'); + body.set('sr', input.subreddit); + body.set('title', input.title); + body.set('kind', input.kind); + if (input.kind === 'self' && input.text !== undefined) { + body.set('text', input.text); + } + if ((input.kind === 'link' || input.kind === 'image') && input.url !== undefined) { + body.set('url', input.url); + } + if (input.flair !== undefined) { + body.set('flair_id', input.flair); + } + if (input.sendReplies !== undefined) { + body.set('sendreplies', input.sendReplies ? 'true' : 'false'); + } + + const response = await redditRequest(context, '/api/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + const errors = response.json?.errors; + if (Array.isArray(errors) && errors.length > 0) { + return { + success: false, + error: `Reddit API error: ${formatRedditErrors(errors)}`, + }; + } + + const data = response.json?.data; + const postId = data?.id; + const postName = data?.name; + const postUrl = data?.url; + if (!postId || !postName || !postUrl) { + return { + success: false, + error: 'Reddit API returned an invalid create post response.', + }; + } + + const permalink = `https://www.reddit.com/r/${encodeURIComponent(input.subreddit)}/comments/${encodeURIComponent(postId)}/`; + + return { + success: true, + output: { + postId, + postName, + url: postUrl, + permalink, + title: input.title, + subreddit: input.subreddit, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create Reddit post', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/social/reddit-extended.test.ts b/packages/nodes/src/integrations/social/reddit-extended.test.ts new file mode 100644 index 0000000..000a469 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-extended.test.ts @@ -0,0 +1,819 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + redditCredential, + redditCreatePostNode, + RedditCreatePostInputSchema, + redditCreateCommentNode, + redditReplyToCommentNode, + redditSearchPostsNode, + RedditSearchPostsInputSchema, + redditGetPostCommentsNode, +} from './index.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const baseContext = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// ─── Credential ─────────────────────────────────────────────────────────────── + +describe('reddit credential', () => { + it('defines oauth2 credential metadata', () => { + expect(redditCredential.name).toBe('reddit'); + expect(redditCredential.type).toBe('oauth2'); + expect(redditCredential.config.authorizationUrl).toBe( + 'https://www.reddit.com/api/v1/authorize' + ); + expect(redditCredential.config.tokenUrl).toBe( + 'https://www.reddit.com/api/v1/access_token' + ); + }); + + it('includes required scopes', () => { + const scopes = redditCredential.config.scopes; + expect(scopes).toEqual(['identity', 'submit', 'read', 'history', 'mysubreddits']); + }); + + it('schema validates complete credentials', () => { + const result = redditCredential.schema.safeParse({ + clientId: 'c1', + clientSecret: 's1', + accessToken: 'a1', + refreshToken: 'r1', + expiresAt: 1234567890, + }); + expect(result.success).toBe(true); + }); + + it('schema rejects missing accessToken', () => { + const result = redditCredential.schema.safeParse({ + clientId: 'c1', + clientSecret: 's1', + }); + expect(result.success).toBe(false); + }); +}); + +// ─── reddit_create_post ─────────────────────────────────────────────────────── + +describe('reddit_create_post', () => { + it('fails when access token is missing', async () => { + const result = await redditCreatePostNode.executor( + { subreddit: 'typescript', title: 'hello', kind: 'self', text: 'body' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('credentials.reddit.accessToken'); + }); + + it('creates self post with form-encoded body', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + id: 'abc123', + name: 't3_abc123', + url: 'https://www.reddit.com/r/typescript/comments/abc123/hello/', + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreatePostNode.executor( + { + subreddit: 'typescript', + title: 'hello', + kind: 'self', + text: 'this is the body', + sendReplies: true, + }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output).toMatchObject({ + postId: 'abc123', + postName: 't3_abc123', + subreddit: 'typescript', + title: 'hello', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://oauth.reddit.com/api/submit'); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers.Authorization).toBe('Bearer r_token'); + expect(headers['User-Agent']).toBe('jam-nodes/1.0'); + expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const body = String(init.body); + expect(body).toContain('kind=self'); + expect(body).toContain('sr=typescript'); + expect(body).toContain('title=hello'); + expect(body).toContain('text=this+is+the+body'); + expect(body).toContain('sendreplies=true'); + expect(body).toContain('api_type=json'); + }); + + it('creates link post with url', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + id: 'link1', + name: 't3_link1', + url: 'https://example.com/', + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreatePostNode.executor( + { + subreddit: 'webdev', + title: 'check this', + kind: 'link', + url: 'https://example.com/', + }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = String(init.body); + expect(body).toContain('kind=link'); + expect(body).toContain('url=https%3A%2F%2Fexample.com%2F'); + }); + + it('rejects self post without text at schema level', () => { + const result = RedditCreatePostInputSchema.safeParse({ + subreddit: 'typescript', + title: 'hello', + kind: 'self', + }); + expect(result.success).toBe(false); + }); + + it('returns error when reddit json.errors is non-empty', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [['SUBREDDIT_NOEXIST', 'that subreddit does not exist', null]], + data: null, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreatePostNode.executor( + { + subreddit: 'nonexistent_xyz', + title: 'hello', + kind: 'self', + text: 'body', + }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('SUBREDDIT_NOEXIST'); + }); + + it('encodes special characters in title', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { id: 'x1', name: 't3_x1', url: 'https://www.reddit.com/x' }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditCreatePostNode.executor( + { + subreddit: 'typescript', + title: 'hello & world', + kind: 'self', + text: 'body', + }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = String(init.body); + expect(body).toContain('title=hello+%26+world'); + }); +}); + +// ─── reddit_create_comment ──────────────────────────────────────────────────── + +describe('reddit_create_comment', () => { + it('fails when access token is missing', async () => { + const result = await redditCreateCommentNode.executor( + { postId: 'abc123', text: 'nice post' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('credentials.reddit.accessToken'); + }); + + it('creates comment on post with t3_ prefix added', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + things: [ + { + kind: 't1', + data: { + id: 'c1', + name: 't1_c1', + permalink: '/r/typescript/comments/abc123/_/c1/', + }, + }, + ], + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreateCommentNode.executor( + { postId: 'abc123', text: 'nice post' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ + commentId: 'c1', + commentName: 't1_c1', + permalink: 'https://www.reddit.com/r/typescript/comments/abc123/_/c1/', + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://oauth.reddit.com/api/comment'); + const body = String(init.body); + expect(body).toContain('thing_id=t3_abc123'); + expect(body).toContain('text=nice+post'); + }); + + it('passes through existing t3_ prefix', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + things: [ + { + kind: 't1', + data: { id: 'c2', name: 't1_c2', permalink: '/x/' }, + }, + ], + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditCreateCommentNode.executor( + { postId: 't3_abc123', text: 'hi' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = String(init.body); + expect(body).toContain('thing_id=t3_abc123'); + expect(body).not.toContain('thing_id=t3_t3_'); + }); + + it('returns error when reddit json.errors is non-empty', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [['TOO_OLD', 'this thing is archived', 'text']], + data: null, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreateCommentNode.executor( + { postId: 'abc123', text: 'hi' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('TOO_OLD'); + }); + + it('parses comment id and permalink from response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + things: [ + { + kind: 't1', + data: { + id: 'xyz9', + name: 't1_xyz9', + permalink: '/r/foo/comments/abc/_/xyz9/', + }, + }, + ], + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditCreateCommentNode.executor( + { postId: 'abc', text: 'hi' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.commentId).toBe('xyz9'); + expect(result.output?.commentName).toBe('t1_xyz9'); + expect(result.output?.permalink).toBe('https://www.reddit.com/r/foo/comments/abc/_/xyz9/'); + }); +}); + +// ─── reddit_reply_to_comment ────────────────────────────────────────────────── + +describe('reddit_reply_to_comment', () => { + it('fails when access token is missing', async () => { + const result = await redditReplyToCommentNode.executor( + { commentId: 'xyz789', text: 'reply' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('credentials.reddit.accessToken'); + }); + + it('replies to comment with t1_ prefix added', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + things: [ + { + kind: 't1', + data: { id: 'r1', name: 't1_r1', permalink: '/r/x/_/r1/' }, + }, + ], + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditReplyToCommentNode.executor( + { commentId: 'xyz789', text: 'reply text' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://oauth.reddit.com/api/comment'); + const body = String(init.body); + expect(body).toContain('thing_id=t1_xyz789'); + expect(body).toContain('text=reply+text'); + }); + + it('passes through existing t1_ prefix', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [], + data: { + things: [ + { + kind: 't1', + data: { id: 'r2', name: 't1_r2', permalink: '/x/' }, + }, + ], + }, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditReplyToCommentNode.executor( + { commentId: 't1_xyz789', text: 'hi' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = String(init.body); + expect(body).toContain('thing_id=t1_xyz789'); + expect(body).not.toContain('thing_id=t1_t1_'); + }); + + it('returns error when reddit json.errors is non-empty', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + json: { + errors: [['RATELIMIT', 'you are doing that too much', null]], + data: null, + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditReplyToCommentNode.executor( + { commentId: 'xyz789', text: 'hi' }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('RATELIMIT'); + }); +}); + +// ─── reddit_search_posts ────────────────────────────────────────────────────── + +describe('reddit_search_posts', () => { + it('fails when access token is missing', async () => { + const result = await redditSearchPostsNode.executor( + { query: 'typescript', sort: 'new', time: 'day', limit: 25 }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('credentials.reddit.accessToken'); + }); + + it('searches across all subreddits when no subreddit given', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + data: { after: null, children: [] }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditSearchPostsNode.executor( + { query: 'typescript', sort: 'new', time: 'day', limit: 25 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('https://oauth.reddit.com/search?'); + expect(url).toContain('q=typescript'); + expect(url).toContain('restrict_sr=off'); + }); + + it('restricts to subreddit when provided', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ data: { after: null, children: [] } }) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditSearchPostsNode.executor( + { query: 'hooks', subreddit: 'typescript', sort: 'new', time: 'day', limit: 25 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('https://oauth.reddit.com/r/typescript/search?'); + expect(url).toContain('restrict_sr=on'); + }); + + it('applies default sort and time via schema', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ data: { after: null, children: [] } }) + ); + vi.stubGlobal('fetch', fetchMock); + + // Rely on schema defaults — caller only passes query. + const result = await redditSearchPostsNode.executor( + RedditSearchPostsInputSchema.parse({ query: 'hooks' }), + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('sort=new'); + expect(url).toContain('t=day'); + expect(url).toContain('limit=25'); + }); + + it('rejects limit above 100 at schema level', () => { + const result = RedditSearchPostsInputSchema.safeParse({ + query: 'x', + limit: 500, + }); + expect(result.success).toBe(false); + }); + + it('returns normalized post shape with after token', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + data: { + after: 't3_last', + children: [ + { + kind: 't3', + data: { + id: 'p1', + name: 't3_p1', + title: 'First', + author: 'alice', + subreddit: 'typescript', + selftext: 'body', + url: 'https://www.reddit.com/r/typescript/comments/p1/', + permalink: '/r/typescript/comments/p1/', + score: 42, + num_comments: 7, + created_utc: 1700000000, + upvote_ratio: 0.95, + }, + }, + { + kind: 't3', + data: { + id: 'p2', + name: 't3_p2', + title: 'Second', + author: 'bob', + subreddit: 'typescript', + selftext: '', + url: 'https://example.com/', + permalink: '/r/typescript/comments/p2/', + score: 10, + num_comments: 2, + created_utc: 1700000100, + upvote_ratio: 0.8, + }, + }, + ], + }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditSearchPostsNode.executor( + { query: 'typescript', sort: 'new', time: 'day', limit: 25 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.after).toBe('t3_last'); + expect(result.output?.resultCount).toBe(2); + expect(result.output?.posts[0]).toMatchObject({ + id: 'p1', + title: 'First', + author: 'alice', + score: 42, + numComments: 7, + permalink: 'https://www.reddit.com/r/typescript/comments/p1/', + }); + }); + + it('handles empty result set', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ data: { after: null, children: [] } }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditSearchPostsNode.executor( + { query: 'nonexistent_xyz_qqq', sort: 'new', time: 'day', limit: 25 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output).toEqual({ posts: [], after: null, resultCount: 0 }); + }); +}); + +// ─── reddit_get_post_comments ───────────────────────────────────────────────── + +describe('reddit_get_post_comments', () => { + it('fails when access token is missing', async () => { + const result = await redditGetPostCommentsNode.executor( + { postId: 'abc123', sort: 'confidence', limit: 100 }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('credentials.reddit.accessToken'); + }); + + it('fetches comments for post with sort and limit', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([ + { data: { children: [] } }, + { data: { children: [] } }, + ]) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditGetPostCommentsNode.executor( + { postId: 'abc123', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('https://oauth.reddit.com/comments/abc123?'); + expect(url).toContain('sort=confidence'); + expect(url).toContain('limit=100'); + const headers = init.headers as Record; + expect(headers['User-Agent']).toBe('jam-nodes/1.0'); + }); + + it('strips t3_ prefix from postId', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([{ data: { children: [] } }, { data: { children: [] } }]) + ); + vi.stubGlobal('fetch', fetchMock); + + await redditGetPostCommentsNode.executor( + { postId: 't3_abc123', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/comments/abc123?'); + expect(url).not.toContain('/comments/t3_'); + }); + + it('flattens nested replies with depth', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([ + { data: { children: [] } }, + { + data: { + children: [ + { + kind: 't1', + data: { + id: 'c1', + name: 't1_c1', + author: 'alice', + body: 'top level', + score: 5, + created_utc: 1700000000, + permalink: '/r/x/_/c1/', + parent_id: 't3_abc123', + replies: { + kind: 'Listing', + data: { + children: [ + { + kind: 't1', + data: { + id: 'c2', + name: 't1_c2', + author: 'bob', + body: 'nested', + score: 2, + created_utc: 1700000100, + permalink: '/r/x/_/c2/', + parent_id: 't1_c1', + replies: '', + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + ]) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditGetPostCommentsNode.executor( + { postId: 'abc123', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.count).toBe(2); + expect(result.output?.comments[0]).toMatchObject({ id: 'c1', depth: 0, author: 'alice' }); + expect(result.output?.comments[1]).toMatchObject({ id: 'c2', depth: 1, author: 'bob' }); + }); + + it('skips "more" stubs', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([ + { data: { children: [] } }, + { + data: { + children: [ + { + kind: 't1', + data: { + id: 'real', + name: 't1_real', + author: 'alice', + body: 'hi', + score: 1, + created_utc: 0, + permalink: '/x/', + parent_id: 't3_p1', + replies: '', + }, + }, + { + kind: 'more', + data: { count: 10, children: ['m1', 'm2'] }, + }, + ], + }, + }, + ]) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditGetPostCommentsNode.executor( + { postId: 'p1', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.count).toBe(1); + expect(result.output?.comments[0]?.id).toBe('real'); + }); + + it('handles empty comments', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([{ data: { children: [] } }, { data: { children: [] } }]) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditGetPostCommentsNode.executor( + { postId: 'p1', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.count).toBe(0); + expect(result.output?.comments).toEqual([]); + }); + + it('handles deeply nested threads without stack overflow', async () => { + // Build a chain of 50 nested replies. + const buildChain = (depthLeft: number, idCounter: { n: number }): unknown => { + const id = `c${idCounter.n++}`; + const node: Record = { + kind: 't1', + data: { + id, + name: `t1_${id}`, + author: 'alice', + body: `body ${id}`, + score: 1, + created_utc: 0, + permalink: `/x/${id}/`, + parent_id: 't3_p1', + replies: '', + }, + }; + if (depthLeft > 0) { + (node.data as Record).replies = { + kind: 'Listing', + data: { children: [buildChain(depthLeft - 1, idCounter)] }, + }; + } + return node; + }; + + const chain = buildChain(49, { n: 0 }); + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse([{ data: { children: [] } }, { data: { children: [chain] } }]) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await redditGetPostCommentsNode.executor( + { postId: 'p1', sort: 'confidence', limit: 100 }, + { ...baseContext, credentials: { reddit: { accessToken: 'r_token' } } } + ); + + expect(result.success).toBe(true); + expect(result.output?.count).toBe(50); + expect(result.output?.comments[0]?.depth).toBe(0); + expect(result.output?.comments[49]?.depth).toBe(49); + }); +}); diff --git a/packages/nodes/src/integrations/social/reddit-get-post-comments.ts b/packages/nodes/src/integrations/social/reddit-get-post-comments.ts new file mode 100644 index 0000000..e471dc6 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-get-post-comments.ts @@ -0,0 +1,166 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { getRedditAccessToken, redditRequest } from './reddit-client.js'; + +interface RedditCommentData { + id?: string; + name?: string; + author?: string; + body?: string; + score?: number; + created_utc?: number; + permalink?: string; + parent_id?: string; + replies?: RedditCommentListing | '' | null; +} + +interface RedditCommentChild { + kind?: string; + data?: RedditCommentData; +} + +interface RedditCommentListing { + kind?: string; + data?: { + children?: RedditCommentChild[]; + }; +} + +type RedditGetCommentsResponse = RedditCommentListing[]; + +export const RedditGetPostCommentsInputSchema = z.object({ + postId: z.string().min(1), + sort: z + .enum(['confidence', 'top', 'new', 'controversial', 'old', 'qa']) + .optional() + .default('confidence'), + limit: z.number().int().min(1).max(500).optional().default(100), +}); + +export type RedditGetPostCommentsInput = z.infer; + +const RedditCommentSchema = z.object({ + id: z.string(), + name: z.string(), + author: z.string(), + body: z.string(), + score: z.number(), + createdUtc: z.number(), + permalink: z.string(), + parentId: z.string(), + depth: z.number(), +}); + +export const RedditGetPostCommentsOutputSchema = z.object({ + postId: z.string(), + comments: z.array(RedditCommentSchema), + count: z.number(), +}); + +export type RedditGetPostCommentsOutput = z.infer; + +function stripPostIdPrefix(postId: string): string { + if (postId.startsWith('t3_')) { + return postId.slice(3); + } + return postId; +} + +interface QueueEntry { + child: RedditCommentChild; + depth: number; +} + +function flattenCommentTree( + root: RedditCommentListing | undefined +): z.infer[] { + const flat: z.infer[] = []; + if (!root || !Array.isArray(root.data?.children)) return flat; + + const queue: QueueEntry[] = root.data!.children!.map((child) => ({ child, depth: 0 })); + + while (queue.length > 0) { + const entry = queue.shift()!; + const { child, depth } = entry; + + if (child.kind !== 't1' || !child.data) { + // Skip 'more' stubs and anything that isn't a comment. + continue; + } + + const d = child.data; + flat.push({ + id: d.id ?? '', + name: d.name ?? '', + author: d.author ?? '', + body: d.body ?? '', + score: d.score ?? 0, + createdUtc: d.created_utc ?? 0, + permalink: d.permalink ? `https://www.reddit.com${d.permalink}` : '', + parentId: d.parent_id ?? '', + depth, + }); + + const replies = d.replies; + if (replies && typeof replies === 'object' && Array.isArray(replies.data?.children)) { + for (const reply of replies.data!.children!) { + queue.push({ child: reply, depth: depth + 1 }); + } + } + } + + return flat; +} + +export const redditGetPostCommentsNode = defineNode({ + type: 'reddit_get_post_comments', + name: 'Reddit Get Post Comments', + description: 'Fetch the comment tree for a Reddit post (flattened, with depth)', + category: 'integration', + inputSchema: RedditGetPostCommentsInputSchema, + outputSchema: RedditGetPostCommentsOutputSchema, + estimatedDuration: 10, + capabilities: { + supportsRerun: true, + }, + executor: async (input, context) => { + try { + if (!getRedditAccessToken(context)) { + return { + success: false, + error: + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.', + }; + } + + const strippedId = stripPostIdPrefix(input.postId); + const params = new URLSearchParams(); + params.set('sort', String(input.sort)); + params.set('limit', String(input.limit)); + + const path = `/comments/${encodeURIComponent(strippedId)}?${params.toString()}`; + + const response = await redditRequest(context, path, { + method: 'GET', + }); + + // Response is [postListing, commentListing]. We only need the second. + const commentListing = Array.isArray(response) ? response[1] : undefined; + const comments = flattenCommentTree(commentListing); + + return { + success: true, + output: { + postId: strippedId, + comments, + count: comments.length, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Reddit post comments', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/social/reddit-reply-to-comment.ts b/packages/nodes/src/integrations/social/reddit-reply-to-comment.ts new file mode 100644 index 0000000..883cd8f --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-reply-to-comment.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { getRedditAccessToken, redditRequest, formatRedditErrors } from './reddit-client.js'; + +interface RedditCommentResponse { + json?: { + errors?: unknown[]; + data?: { + things?: Array<{ + kind?: string; + data?: { + id?: string; + name?: string; + permalink?: string; + }; + }>; + }; + }; +} + +export const RedditReplyToCommentInputSchema = z.object({ + commentId: z.string().min(1), + text: z.string().min(1), +}); + +export type RedditReplyToCommentInput = z.infer; + +export const RedditReplyToCommentOutputSchema = z.object({ + commentId: z.string(), + commentName: z.string(), + permalink: z.string(), +}); + +export type RedditReplyToCommentOutput = z.infer; + +function normalizeCommentThingId(commentId: string): string { + if (commentId.startsWith('t1_') || commentId.startsWith('t3_')) { + return commentId; + } + return `t1_${commentId}`; +} + +export const redditReplyToCommentNode = defineNode({ + type: 'reddit_reply_to_comment', + name: 'Reddit Reply To Comment', + description: 'Post a reply to an existing Reddit comment', + category: 'integration', + inputSchema: RedditReplyToCommentInputSchema, + outputSchema: RedditReplyToCommentOutputSchema, + estimatedDuration: 6, + capabilities: { + supportsRerun: true, + }, + executor: async (input, context) => { + try { + if (!getRedditAccessToken(context)) { + return { + success: false, + error: + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.', + }; + } + + const body = new URLSearchParams(); + body.set('api_type', 'json'); + body.set('thing_id', normalizeCommentThingId(input.commentId)); + body.set('text', input.text); + + const response = await redditRequest(context, '/api/comment', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + const errors = response.json?.errors; + if (Array.isArray(errors) && errors.length > 0) { + return { + success: false, + error: `Reddit API error: ${formatRedditErrors(errors)}`, + }; + } + + const thing = response.json?.data?.things?.[0]; + const commentId = thing?.data?.id; + const commentName = thing?.data?.name; + const rawPermalink = thing?.data?.permalink; + + if (!commentId || !commentName) { + return { + success: false, + error: 'Reddit API returned an invalid reply response.', + }; + } + + const permalink = rawPermalink ? `https://www.reddit.com${rawPermalink}` : ''; + + return { + success: true, + output: { + commentId, + commentName, + permalink, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to reply to Reddit comment', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/social/reddit-search-posts.ts b/packages/nodes/src/integrations/social/reddit-search-posts.ts new file mode 100644 index 0000000..e0b08f5 --- /dev/null +++ b/packages/nodes/src/integrations/social/reddit-search-posts.ts @@ -0,0 +1,137 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { getRedditAccessToken, redditRequest } from './reddit-client.js'; + +interface RedditListingResponse { + kind?: string; + data?: { + after?: string | null; + children?: Array<{ + kind?: string; + data?: { + id?: string; + name?: string; + title?: string; + author?: string; + subreddit?: string; + selftext?: string; + url?: string; + permalink?: string; + score?: number; + num_comments?: number; + created_utc?: number; + upvote_ratio?: number; + }; + }>; + }; +} + +export const RedditSearchPostsInputSchema = z.object({ + query: z.string().min(1), + subreddit: z.string().optional(), + sort: z.enum(['relevance', 'hot', 'top', 'new', 'comments']).optional().default('new'), + time: z.enum(['hour', 'day', 'week', 'month', 'year', 'all']).optional().default('day'), + limit: z.number().int().min(1).max(100).optional().default(25), +}); + +export type RedditSearchPostsInput = z.infer; + +const SearchedPostSchema = z.object({ + id: z.string(), + name: z.string(), + title: z.string(), + author: z.string(), + subreddit: z.string(), + selftext: z.string(), + url: z.string(), + permalink: z.string(), + score: z.number(), + numComments: z.number(), + createdUtc: z.number(), + upvoteRatio: z.number(), +}); + +export const RedditSearchPostsOutputSchema = z.object({ + posts: z.array(SearchedPostSchema), + after: z.string().nullable(), + resultCount: z.number(), +}); + +export type RedditSearchPostsOutput = z.infer; + +export const redditSearchPostsNode = defineNode({ + type: 'reddit_search_posts', + name: 'Reddit Search Posts', + description: 'Search Reddit posts (optionally restricted to a subreddit)', + category: 'integration', + inputSchema: RedditSearchPostsInputSchema, + outputSchema: RedditSearchPostsOutputSchema, + estimatedDuration: 10, + capabilities: { + supportsRerun: true, + }, + executor: async (input, context) => { + try { + if (!getRedditAccessToken(context)) { + return { + success: false, + error: + 'Reddit access token not configured. Please provide context.credentials.reddit.accessToken.', + }; + } + + const params = new URLSearchParams(); + params.set('q', input.query); + params.set('sort', String(input.sort)); + params.set('t', String(input.time)); + params.set('limit', String(input.limit)); + params.set('type', 'link'); + params.set('restrict_sr', input.subreddit ? 'on' : 'off'); + + const path = input.subreddit + ? `/r/${encodeURIComponent(input.subreddit)}/search?${params.toString()}` + : `/search?${params.toString()}`; + + const response = await redditRequest(context, path, { + method: 'GET', + }); + + const children = response.data?.children || []; + const posts = children.map((child) => { + const d = child.data || {}; + const permalink = d.permalink ? `https://www.reddit.com${d.permalink}` : ''; + return { + id: d.id ?? '', + name: d.name ?? '', + title: d.title ?? '', + author: d.author ?? '', + subreddit: d.subreddit ?? '', + selftext: d.selftext ?? '', + url: d.url ?? '', + permalink, + score: d.score ?? 0, + numComments: d.num_comments ?? 0, + createdUtc: d.created_utc ?? 0, + upvoteRatio: d.upvote_ratio ?? 0, + }; + }); + + const rawAfter = response.data?.after; + const after = typeof rawAfter === 'string' ? rawAfter : null; + + return { + success: true, + output: { + posts, + after, + resultCount: posts.length, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to search Reddit posts', + }; + } + }, +});