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.')
}