Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/reddit

src/client.ts

413 lines · 11.6 KB · TypeScript
import {
	REDDIT_API_ORIGIN,
	REDDIT_CONNECT_URL,
	REDDIT_SCOPES,
	REDDIT_USER_AGENT,
	redditRequest,
	redditRequestWithResponse,
	resolveIntegration,
} from './request.ts'
import {
	listingPath,
	listingSort,
	normalizeSubreddit,
	normalizeThingId,
	normalizeUsername,
	optionalLimit,
	requireConfirm,
	requireString,
	searchSort,
	shapeCommentListing,
	shapeListing,
	shapeMe,
	shapeSubreddit,
	shapeUser,
	submitKind,
	timeFilter,
	userListing,
	voteDirection,
} from './shape.ts'
import type {
	GetListingInput,
	GetPostInput,
	GetSubredditInput,
	GetUserInput,
	JsonRecord,
	SearchInput,
	ShapedListing,
	SubmitCommentInput,
	SubmitPostInput,
	VoteInput,
} from './types.ts'

export type {
	AccountInput,
	GetListingInput,
	GetPostInput,
	GetSubredditInput,
	GetUserInput,
	JsonRecord,
	ListingSort,
	RedditRequestInput,
	RedditRequestResult,
	SearchInput,
	SearchSort,
	ShapedComment,
	ShapedListing,
	ShapedPost,
	ShapedSubreddit,
	ShapedUser,
	SubmitCommentInput,
	SubmitKind,
	SubmitPostInput,
	TimeFilter,
	UserListing,
	VoteDirection,
	VoteInput,
} from './types.ts'

export {
	RedditApiError,
	REDDIT_API_ORIGIN,
	REDDIT_AUTHORIZE_URL,
	REDDIT_CALLBACK_URL,
	REDDIT_CONNECT_URL,
	REDDIT_DASHBOARD_URL,
	REDDIT_INTEGRATION,
	REDDIT_SCOPES,
	REDDIT_TOKEN_URL,
	REDDIT_USER_AGENT,
	byoConnectUrl,
	redditRequest,
	request,
	resolveIntegration,
} from './request.ts'

export { requireRecord, requireStringField } from './types.ts'

export function accounts(input: GetListingInput | Record<string, unknown> = {}) {
	const integration = resolveIntegration(input)
	return {
		integration,
		account: input.account ?? null,
		convention:
			'integration wins when set. account: "work" → reddit-work. account: "default" / "reddit" / omitted → reddit.',
		connectUrl: 'https://kody.codes/connect/oauth?provider=' + encodeURIComponent(integration),
	}
}

export async function getMe(input: GetListingInput | Record<string, unknown> = {}) {
	const integration = resolveIntegration(input)
	const body = await redditRequest<unknown>('/api/v1/me', { integration })
	return {
		utility: 'get-me',
		integration,
		user: shapeMe(body),
	}
}

export async function smokeTest(input: GetListingInput | Record<string, unknown> = {}) {
	const me = await getMe(input)
	return {
		ok: true,
		integration: me.integration,
		endpoint: REDDIT_API_ORIGIN + '/api/v1/me',
		tokenMode: 'saved OAuth integration',
		hasId: Boolean(me.user.id),
		hasName: Boolean(me.user.name),
		scopes: [...REDDIT_SCOPES],
		connectUrl: REDDIT_CONNECT_URL,
		userAgent: REDDIT_USER_AGENT,
	}
}

export async function getSubreddit(input: GetSubredditInput) {
	const integration = resolveIntegration(input)
	const subreddit = normalizeSubreddit(input.subreddit)
	const body = await redditRequest<unknown>('/r/' + encodeURIComponent(subreddit) + '/about', {
		integration,
	})
	return {
		utility: 'get-subreddit',
		integration,
		subreddit: shapeSubreddit(body),
	}
}

export async function getListing(input: GetListingInput = {}) {
	const integration = resolveIntegration(input)
	const sort = listingSort(input.sort)
	const subreddit = input.subreddit ? normalizeSubreddit(input.subreddit) : undefined
	const t = timeFilter(input.t)
	const query: Record<string, string | number> = {
		limit: optionalLimit(input.limit),
	}
	if (t) query.t = t
	if (input.after) query.after = requireString(input.after, 'after must be a listing cursor.')
	if (input.before) query.before = requireString(input.before, 'before must be a listing cursor.')
	const path = listingPath(subreddit, sort)
	const body = await redditRequest<unknown>(path, { integration, query })
	return {
		utility: 'get-listing',
		integration,
		path,
		sort,
		subreddit: subreddit ?? null,
		listing: shapeListing(body),
	}
}

export async function searchReddit(input: SearchInput) {
	const integration = resolveIntegration(input)
	const queryText = requireString(input.query, 'search requires params.query.')
	const subreddit = input.subreddit ? normalizeSubreddit(input.subreddit) : undefined
	const sort = searchSort(input.sort)
	const t = timeFilter(input.t)
	const restrict = input.restrictToSubreddit === true || Boolean(subreddit && input.restrictToSubreddit !== false)
	const query: Record<string, string | number | boolean> = {
		q: queryText,
		sort,
		limit: optionalLimit(input.limit),
		restrict_sr: Boolean(subreddit) && restrict,
		type: 'link',
	}
	if (t) query.t = t
	if (input.after) query.after = requireString(input.after, 'after must be a listing cursor.')
	const path = subreddit ? '/r/' + encodeURIComponent(subreddit) + '/search' : '/search'
	const body = await redditRequest<unknown>(path, { integration, query })
	return {
		utility: 'search',
		integration,
		path,
		query: queryText,
		subreddit: subreddit ?? null,
		sort,
		listing: shapeListing(body),
	}
}

export async function getUser(input: GetUserInput) {
	const integration = resolveIntegration(input)
	const username = normalizeUsername(input.username)
	const listing = userListing(input.listing)
	const base = '/user/' + encodeURIComponent(username)
	if (listing === 'about') {
		const body = await redditRequest<unknown>(base + '/about', { integration })
		return {
			utility: 'get-user',
			integration,
			listing,
			user: shapeUser(body),
		}
	}
	const sort = listingSort(input.sort, 'new')
	const t = timeFilter(input.t)
	const query: Record<string, string | number> = {
		limit: optionalLimit(input.limit),
		sort,
	}
	if (t) query.t = t
	if (input.after) query.after = requireString(input.after, 'after must be a listing cursor.')
	const body = await redditRequest<unknown>(base + '/' + listing, { integration, query })
	return {
		utility: 'get-user',
		integration,
		listing,
		username,
		sort,
		listingData: shapeUserContent(body),
	}
}

export async function getPost(input: GetPostInput) {
	const integration = resolveIntegration(input)
	const id = normalizeThingId(input.id, 't3', 'params.id').slice(3)
	const subreddit = input.subreddit ? normalizeSubreddit(input.subreddit) : undefined
	const path = subreddit
		? '/r/' + encodeURIComponent(subreddit) + '/comments/' + encodeURIComponent(id)
		: '/comments/' + encodeURIComponent(id)
	const query: Record<string, string | number> = {}
	if (input.commentLimit !== undefined) query.limit = optionalLimit(input.commentLimit)
	if (input.commentSort) query.sort = String(input.commentSort)
	const body = await redditRequest<unknown>(path, { integration, query })
	if (!Array.isArray(body) || body.length < 1) {
		throw new Error('Reddit comments response did not include the post listing.')
	}
	const postListing = shapeListing(body[0])
	const comments = body.length > 1 ? shapeCommentListing(body[1]) : []
	return {
		utility: 'get-post',
		integration,
		path,
		post: postListing.posts[0] ?? null,
		comments,
	}
}

export async function submitPost(input: SubmitPostInput): Promise<JsonRecord> {
	const integration = resolveIntegration(input)
	const subreddit = normalizeSubreddit(input.subreddit)
	const title = requireString(input.title, 'submit-post requires params.title.')
	const kind = submitKind(input.kind, input.url ? 'link' : 'self')
	const form = new URLSearchParams({
		api_type: 'json',
		kind,
		sr: subreddit,
		title,
	})
	if (kind === 'link') {
		form.set('url', requireString(input.url, 'submit-post kind=link requires params.url.'))
	} else {
		form.set('text', input.text ?? '')
	}
	if (input.nsfw === true) form.set('nsfw', 'true')
	if (input.spoiler === true) form.set('spoiler', 'true')
	if (input.sendreplies !== undefined) form.set('sendreplies', input.sendreplies ? 'true' : 'false')
	if (input.flairId) form.set('flair_id', input.flairId)
	if (input.flairText) form.set('flair_text', input.flairText)

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'submit-post',
			method: 'POST',
			url: REDDIT_API_ORIGIN + '/api/submit',
			integration,
			requiredScope: 'submit',
			payload: Object.fromEntries(form),
			requiresConfirm: true,
			publishNote: 'Pass params.confirm = true only after explicit user approval to submit this post.',
		}
	}

	requireConfirm(
		input.confirm,
		'submit-post publishes to Reddit. Pass params.confirm = true only after explicit user approval.',
	)
	const result = await redditRequestWithResponse('/api/submit', {
		method: 'POST',
		body: form,
		integration,
	})
	return {
		utility: 'submit-post',
		integration,
		ok: result.ok,
		status: result.status,
		body: result.body,
	}
}

export async function submitComment(input: SubmitCommentInput): Promise<JsonRecord> {
	const integration = resolveIntegration(input)
	const parentId = normalizeParentId(input.parentId)
	const text = requireString(input.text, 'submit-comment requires params.text.')
	const form = new URLSearchParams({
		api_type: 'json',
		thing_id: parentId,
		text,
	})

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'submit-comment',
			method: 'POST',
			url: REDDIT_API_ORIGIN + '/api/comment',
			integration,
			requiredScope: 'submit',
			payload: Object.fromEntries(form),
			requiresConfirm: true,
			publishNote: 'Pass params.confirm = true only after explicit user approval to submit this comment.',
		}
	}

	requireConfirm(
		input.confirm,
		'submit-comment publishes to Reddit. Pass params.confirm = true only after explicit user approval.',
	)
	const result = await redditRequestWithResponse('/api/comment', {
		method: 'POST',
		body: form,
		integration,
	})
	return {
		utility: 'submit-comment',
		integration,
		ok: result.ok,
		status: result.status,
		body: result.body,
	}
}

export async function vote(input: VoteInput): Promise<JsonRecord> {
	const integration = resolveIntegration(input)
	const id = normalizeVoteId(input.id)
	const dir = voteDirection(input.direction)
	const form = new URLSearchParams({
		id,
		dir: String(dir),
	})

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'vote',
			method: 'POST',
			url: REDDIT_API_ORIGIN + '/api/vote',
			integration,
			requiredScope: 'vote',
			payload: Object.fromEntries(form),
			requiresConfirm: true,
			publishNote: 'Pass params.confirm = true only after explicit user approval to cast this vote.',
		}
	}

	requireConfirm(
		input.confirm,
		'vote changes a Reddit score. Pass params.confirm = true only after explicit user approval.',
	)
	const result = await redditRequestWithResponse('/api/vote', {
		method: 'POST',
		body: form,
		integration,
	})
	return {
		utility: 'vote',
		integration,
		ok: result.ok,
		status: result.status,
		id,
		direction: dir,
		body: result.body,
	}
}

function shapeUserContent(body: unknown): ShapedListing | { comments: ReturnType<typeof shapeCommentListing> } {
	const record = body && typeof body === 'object' ? (body as JsonRecord) : null
	const kind = record && typeof record.kind === 'string' ? record.kind : null
	if (kind === 'Listing') {
		const children = Array.isArray((record?.data as JsonRecord | undefined)?.children)
			? ((record?.data as JsonRecord).children as unknown[])
			: []
		const first = children[0] && typeof children[0] === 'object' ? (children[0] as JsonRecord) : null
		if (first?.kind === 't1') {
			return { comments: shapeCommentListing(body) }
		}
	}
	return shapeListing(body)
}

function normalizeParentId(value: unknown): string {
	const raw = requireString(value, 'submit-comment requires params.parentId.')
	if (raw.startsWith('t1_') || raw.startsWith('t3_')) return raw
	if (/^[A-Za-z0-9]+$/.test(raw)) return 't3_' + raw
	throw new Error('params.parentId must be a Reddit t3_ post or t1_ comment fullname.')
}

function normalizeVoteId(value: unknown): string {
	const raw = requireString(value, 'vote requires params.id.')
	if (raw.startsWith('t1_') || raw.startsWith('t3_')) return raw
	if (/^[A-Za-z0-9]+$/.test(raw)) return 't3_' + raw
	throw new Error('params.id must be a Reddit t3_ post or t1_ comment fullname.')
}