Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

434 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { DEFAULT_MAX_POSTS, redditConnectorMeta } from '@/connectors/reddit/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseTagDate } from '@/connectors/utils'
const logger = createLogger('RedditConnector')
const REDDIT_API_BASE = 'https://oauth.reddit.com'
const REDDIT_USER_AGENT = 'sim-studio:v1.0.0 (knowledge-connector)'
const POSTS_PER_PAGE = 100
const COMMENTS_PER_POST = 15
interface RedditPost {
kind: string
data: {
id: string
name: string
title: string
selftext: string
selftext_html?: string
author: string
score: number
num_comments: number
created_utc: number
permalink: string
url: string
link_flair_text?: string
subreddit: string
is_self: boolean
domain?: string
}
}
interface RedditComment {
kind: string
data: {
id: string
author: string
body: string
score: number
created_utc: number
replies?: RedditListing | string
}
}
interface RedditListing {
kind: string
data: {
children: (RedditPost | RedditComment)[]
after: string | null
}
}
/**
* Makes an authenticated request to the Reddit API.
*/
async function redditApiGet(
path: string,
accessToken: string,
params?: Record<string, string>,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<unknown> {
const queryParams = params ? `?${new URLSearchParams(params).toString()}` : ''
const url = `${REDDIT_API_BASE}${path}${queryParams}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': REDDIT_USER_AGENT,
Accept: 'application/json',
},
},
retryOptions
)
if (!response.ok) {
throw new Error(`Reddit API HTTP error: ${response.status} ${response.statusText}`)
}
return response.json()
}
/**
* Fetches top-level comments for a post, up to a maximum count.
*/
async function fetchPostComments(
accessToken: string,
subreddit: string,
postId: string,
maxComments: number
): Promise<string[]> {
try {
const data = (await redditApiGet(`/r/${subreddit}/comments/${postId}`, accessToken, {
limit: String(maxComments),
depth: '1',
sort: 'top',
})) as RedditListing[]
if (!Array.isArray(data) || data.length < 2) return []
return extractComments(data[1], maxComments)
} catch (error) {
logger.warn('Failed to fetch comments for post', {
postId,
error: toError(error).message,
})
return []
}
}
/**
* Extracts formatted comment strings from a Reddit comment listing.
*/
function extractComments(commentListing: RedditListing, maxComments: number): string[] {
const comments: string[] = []
for (const child of commentListing.data.children) {
if (child.kind !== 't1') continue
const comment = child as RedditComment
if (!comment.data.body || comment.data.author === 'AutoModerator') continue
comments.push(`[${comment.data.author} | score: ${comment.data.score}]: ${comment.data.body}`)
if (comments.length >= maxComments) break
}
return comments
}
/**
* Formats a Reddit post with its comments into a document content string.
* When `prefetchedComments` is provided, uses those directly instead of fetching.
*/
async function formatPostContent(
accessToken: string,
post: RedditPost['data'],
maxComments: number,
prefetchedComments?: string[]
): Promise<string> {
const lines: string[] = []
lines.push(`# ${post.title}`)
lines.push('')
lines.push(`Author: u/${post.author}`)
lines.push(`Score: ${post.score} | Comments: ${post.num_comments}`)
lines.push(`Posted: ${new Date(post.created_utc * 1000).toISOString()}`)
if (post.link_flair_text) {
lines.push(`Flair: ${post.link_flair_text}`)
}
if (!post.is_self && post.url) {
lines.push(`Link: ${post.url}`)
}
lines.push('')
if (post.selftext) {
lines.push(post.selftext)
lines.push('')
}
if (maxComments > 0) {
const comments =
prefetchedComments ??
(await fetchPostComments(accessToken, post.subreddit, post.id, maxComments))
if (comments.length > 0) {
lines.push('---')
lines.push(`Top Comments (${comments.length}):`)
lines.push('')
for (const comment of comments) {
lines.push(comment)
lines.push('')
}
}
}
return lines.join('\n').trim()
}
/**
* Fetches posts from a subreddit listing endpoint, handling pagination.
*/
async function fetchSubredditPosts(
accessToken: string,
subreddit: string,
sort: string,
timeFilter: string,
maxPosts: number,
afterCursor?: string
): Promise<{ posts: RedditPost['data'][]; after: string | null }> {
const allPosts: RedditPost['data'][] = []
let after: string | null = afterCursor ?? null
while (allPosts.length < maxPosts) {
const limit = Math.min(POSTS_PER_PAGE, maxPosts - allPosts.length)
const params: Record<string, string> = { limit: String(limit) }
if (after) {
params.after = after
}
if (sort === 'top') {
params.t = timeFilter
}
const data = (await redditApiGet(
`/r/${subreddit}/${sort}`,
accessToken,
params
)) as RedditListing
const children = data.data.children as RedditPost[]
if (children.length === 0) {
after = null
break
}
for (const child of children) {
if (child.kind === 't3') {
allPosts.push(child.data)
}
}
after = data.data.after
if (!after) break
}
return { posts: allPosts.slice(0, maxPosts), after }
}
/**
* Resolves sort and time filter from source config.
*/
function resolveSortConfig(sourceConfig: Record<string, unknown>): {
sort: string
timeFilter: string
} {
const sort = (sourceConfig.sort as string) || 'hot'
const timeFilter = (sourceConfig.timeFilter as string) || 'week'
return { sort, timeFilter }
}
export const redditConnector: ConnectorConfig = {
...redditConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string
): Promise<ExternalDocumentList> => {
const subreddit = (sourceConfig.subreddit as string)?.trim().replace(/^r\//, '')
if (!subreddit) {
throw new Error('Subreddit is required')
}
const { sort, timeFilter } = resolveSortConfig(sourceConfig)
const maxPosts = sourceConfig.maxPosts ? Number(sourceConfig.maxPosts) : DEFAULT_MAX_POSTS
logger.info('Syncing Reddit subreddit', { subreddit, sort, timeFilter, maxPosts })
// Parse cursor: "after:TOKEN:collected:N" format
let afterToken: string | undefined
let collectedSoFar = 0
if (cursor) {
const parts = cursor.split(':')
if (parts.length >= 4) {
afterToken = parts[1] || undefined
collectedSoFar = Number(parts[3]) || 0
}
}
const remainingPosts = maxPosts - collectedSoFar
const pageBatchSize = Math.min(POSTS_PER_PAGE, remainingPosts)
const { posts, after } = await fetchSubredditPosts(
accessToken,
subreddit,
sort,
timeFilter,
pageBatchSize,
afterToken
)
const documents: ExternalDocument[] = posts.map((post) => ({
externalId: post.id,
title: post.title,
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: `https://www.reddit.com${post.permalink}`,
contentHash: `reddit:${post.id}:${post.created_utc}`,
metadata: {
author: post.author,
score: post.score,
commentCount: post.num_comments,
flair: post.link_flair_text ?? undefined,
postDate: new Date(post.created_utc * 1000).toISOString(),
subreddit: post.subreddit,
},
}))
const totalCollected = collectedSoFar + documents.length
const hasMore = after !== null && totalCollected < maxPosts
return {
documents,
hasMore,
nextCursor: hasMore ? `after:${after}:collected:${totalCollected}` : undefined,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const subreddit = (sourceConfig.subreddit as string)?.trim().replace(/^r\//, '')
if (!subreddit) return null
try {
const data = (await redditApiGet(`/r/${subreddit}/comments/${externalId}`, accessToken, {
limit: String(COMMENTS_PER_POST),
depth: '1',
sort: 'top',
})) as RedditListing[]
if (!Array.isArray(data) || data.length === 0) return null
const postListing = data[0]
const postChildren = postListing.data.children as RedditPost[]
if (postChildren.length === 0) return null
const post = postChildren[0].data
const comments =
data.length >= 2 ? extractComments(data[1] as RedditListing, COMMENTS_PER_POST) : []
const content = await formatPostContent(accessToken, post, COMMENTS_PER_POST, comments)
return {
externalId: post.id,
title: post.title,
content,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: `https://www.reddit.com${post.permalink}`,
contentHash: `reddit:${post.id}:${post.created_utc}`,
metadata: {
author: post.author,
score: post.score,
commentCount: post.num_comments,
flair: post.link_flair_text ?? undefined,
postDate: new Date(post.created_utc * 1000).toISOString(),
subreddit: post.subreddit,
},
}
} catch (error) {
logger.warn('Failed to get Reddit post document', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const subredditInput = (sourceConfig.subreddit as string | undefined)
?.trim()
.replace(/^r\//, '')
const maxPosts = sourceConfig.maxPosts as string | undefined
if (!subredditInput) {
return { valid: false, error: 'Subreddit is required' }
}
if (maxPosts && (Number.isNaN(Number(maxPosts)) || Number(maxPosts) <= 0)) {
return { valid: false, error: 'Max posts must be a positive number' }
}
try {
const data = (await redditApiGet(
`/r/${subredditInput}/about`,
accessToken,
{},
VALIDATE_RETRY_OPTIONS
)) as { kind: string; data: { display_name: string; subreddit_type?: string } }
if (data.data?.subreddit_type === 'private') {
return { valid: false, error: `Subreddit r/${subredditInput} is private` }
}
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
if (message.includes('404') || message.includes('403')) {
return {
valid: false,
error: `Subreddit r/${subredditInput} not found or is not accessible`,
}
}
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.author === 'string') {
result.author = metadata.author
}
if (typeof metadata.score === 'number') {
result.score = metadata.score
}
if (typeof metadata.commentCount === 'number') {
result.commentCount = metadata.commentCount
}
if (typeof metadata.flair === 'string') {
result.flair = metadata.flair
}
const postDate = parseTagDate(metadata.postDate)
if (postDate) {
result.postDate = postDate
}
return result
},
}