import { createAuthenticatedFetch } from 'kody:runtime'
import type {
AccountInput,
JsonRecord,
QueryInput,
RedditRequestInput,
RedditRequestResult,
} from './types.ts'
import { requireRecord, requireStringField } from './types.ts'
export const REDDIT_API_ORIGIN = 'https://oauth.reddit.com'
export const REDDIT_WWW_ORIGIN = 'https://www.reddit.com'
export const REDDIT_INTEGRATION = 'reddit'
export const REDDIT_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const REDDIT_CONNECT_URL = 'https://kody.codes/connect/oauth?provider=reddit'
export const REDDIT_AUTHORIZE_URL = 'https://www.reddit.com/api/v1/authorize'
export const REDDIT_TOKEN_URL = 'https://www.reddit.com/api/v1/access_token'
export const REDDIT_DASHBOARD_URL = 'https://www.reddit.com/prefs/apps'
export const REDDIT_USER_AGENT = 'web:kody.reddit:1.0.0 (https://kody.codes/@kody/reddit)'
export const REDDIT_SCOPES = [
'identity',
'read',
'history',
'mysubreddits',
'submit',
'edit',
'vote',
'save',
] as const
export const REDDIT_SETUP_INSTRUCTIONS =
'Create a Reddit web app at https://www.reddit.com/prefs/apps. App type must be "web app". Redirect URI must be exactly https://kody.codes/connect/oauth. Paste the client id and secret into this Kody form — never into chat. duration=permanent is already requested so Kody can refresh tokens.'
const authedFetchCache = new Map<string, typeof fetch>()
export function byoConnectUrl(): string {
const params = new URLSearchParams({
provider: REDDIT_INTEGRATION,
authorizeUrl: REDDIT_AUTHORIZE_URL,
tokenUrl: REDDIT_TOKEN_URL,
apiBaseUrl: REDDIT_API_ORIGIN,
flow: 'confidential',
tokenExchangeStyle: 'basic-form',
allowedHosts: 'oauth.reddit.com,www.reddit.com',
dashboardUrl: REDDIT_DASHBOARD_URL,
scopes: REDDIT_SCOPES.join(' '),
extraAuthorizeParams: 'duration=permanent',
providerSetupInstructions: REDDIT_SETUP_INSTRUCTIONS,
})
return REDDIT_CALLBACK_URL + '?' + params.toString()
}
export function resolveIntegration(input: AccountInput = {}): string {
if (input.integration) {
return requireName(input.integration, 'params.integration')
}
const account = input.account
if (!account || account === 'default' || account === 'reddit') {
return REDDIT_INTEGRATION
}
const name = requireName(account, 'params.account')
if (name === 'reddit' || name.startsWith('reddit-')) {
return name
}
return 'reddit-' + name
}
export class RedditApiError extends Error {
readonly status: number
readonly statusText: string
readonly details: unknown
readonly integration: string
constructor(
message: string,
options: { status: number; statusText: string; details: unknown; integration: string },
) {
super(message)
this.name = 'RedditApiError'
this.status = options.status
this.statusText = options.statusText
this.details = options.details
this.integration = options.integration
}
}
export async function getRedditAuthenticatedFetch(integration: string): Promise<typeof fetch> {
const cached = authedFetchCache.get(integration)
if (cached) return cached
const authedFetch = await createAuthenticatedFetch(integration)
authedFetchCache.set(integration, authedFetch)
return authedFetch
}
export function buildRedditUrl(path: string, query: QueryInput = {}): string {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Reddit request path is required.')
}
const url = path.startsWith('https://')
? new URL(path)
: new URL(REDDIT_API_ORIGIN + (path.startsWith('/') ? path : '/' + path))
if (url.origin !== REDDIT_API_ORIGIN) {
throw new Error('Reddit request URLs must use https://oauth.reddit.com.')
}
if (!url.searchParams.has('raw_json')) {
url.searchParams.set('raw_json', '1')
}
for (const [key, value] of Object.entries(query)) {
const values = Array.isArray(value) ? value : [value]
for (const item of values) {
if (item !== null && item !== undefined && item !== '') {
url.searchParams.set(key, String(item))
}
}
}
return url.toString()
}
export async function rawRedditRequest(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
integration?: string
} = {},
): Promise<Response> {
const integration = options.integration ?? REDDIT_INTEGRATION
const url = buildRedditUrl(path, options.query)
const headers = new Headers(options.headers)
if (!headers.has('accept')) headers.set('accept', 'application/json')
if (!headers.has('user-agent')) headers.set('user-agent', REDDIT_USER_AGENT)
const method = (options.method || 'GET').toUpperCase()
const init: RequestInit = { method, headers }
if (options.body !== undefined && options.body !== null) {
if (typeof options.body === 'string') {
init.body = options.body
} else if (options.body instanceof URLSearchParams || options.body instanceof ArrayBuffer) {
if (options.body instanceof URLSearchParams && !headers.has('content-type')) {
headers.set('content-type', 'application/x-www-form-urlencoded')
}
init.body = options.body
} else {
if (!headers.has('content-type')) headers.set('content-type', 'application/json')
init.body = JSON.stringify(options.body)
}
}
const authedFetch = await getRedditAuthenticatedFetch(integration)
return authedFetch(url, init)
}
export async function redditRequestWithResponse(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
integration?: string
} = {},
): Promise<RedditRequestResult> {
const integration = options.integration ?? REDDIT_INTEGRATION
const response = await rawRedditRequest(path, { ...options, integration })
const parsedBody = await parseResponseBody(response)
if (!response.ok) {
throw new RedditApiError(
enhanceAuthMessage(
'Reddit API request failed: ' + response.status + ' ' + response.statusText,
response.status,
),
{
status: response.status,
statusText: response.statusText,
details: parsedBody,
integration,
},
)
}
assertRedditPayload(parsedBody, integration)
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: selectResponseHeaders(response.headers),
body: parsedBody,
integration,
}
}
export async function redditRequest<T>(
path: string,
options: {
method?: string
query?: QueryInput
headers?: Record<string, string>
body?: unknown
integration?: string
} = {},
): Promise<T> {
const result = await redditRequestWithResponse(path, options)
return result.body as T
}
/** Public request helper with dry-run / confirm guards for non-GET calls. */
export async function request(
input: RedditRequestInput,
): Promise<RedditRequestResult | JsonRecord> {
const method = (input.method || 'GET').toUpperCase()
const integration = resolveIntegration(input)
const url = buildRedditUrl(input.path, input.query)
const body = input.body ?? null
if (input.dryRun) {
return {
dryRun: true,
method,
url,
body,
integration,
headers: {
...redactAuthHeader(input.headers),
'user-agent': REDDIT_USER_AGENT,
},
requiresConfirm: method !== 'GET' && method !== 'HEAD',
}
}
if (method !== 'GET' && method !== 'HEAD' && input.confirm !== true) {
throw new Error(
'request performs a non-read Reddit API call. Pass params.confirm = true only after explicit user approval.',
)
}
return redditRequestWithResponse(input.path, {
method,
query: input.query,
headers: input.headers,
body: input.body,
integration,
})
}
export async function parseResponseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function assertRedditPayload(body: unknown, integration: string): void {
const record = body && typeof body === 'object' && !Array.isArray(body) ? (body as JsonRecord) : null
if (!record) return
if (typeof record.error === 'number' || typeof record.error === 'string') {
throw new RedditApiError(
enhanceAuthMessage(
'Reddit API returned error ' + String(record.error) + (record.message ? ': ' + String(record.message) : ''),
typeof record.error === 'number' ? record.error : 400,
),
{
status: typeof record.error === 'number' ? record.error : 400,
statusText: stringOrEmpty(record.message),
details: body,
integration,
},
)
}
const json = record.json && typeof record.json === 'object' ? (record.json as JsonRecord) : null
const errors = json && Array.isArray(json.errors) ? json.errors : null
if (errors && errors.length > 0) {
throw new RedditApiError('Reddit API returned errors: ' + JSON.stringify(errors), {
status: 400,
statusText: 'Reddit json.errors',
details: body,
integration,
})
}
}
function enhanceAuthMessage(message: string, status: number): string {
if (status !== 401 && status !== 403) return message
return (
message +
'. Confirm the reddit OAuth integration is connected and current, then reconnect at ' +
REDDIT_CONNECT_URL +
'.'
)
}
function selectResponseHeaders(headers: Headers): Record<string, string> {
const selected = [
'content-type',
'x-ratelimit-used',
'x-ratelimit-remaining',
'x-ratelimit-reset',
]
const output: Record<string, string> = {}
for (const key of selected) {
const value = headers.get(key)
if (value) output[key] = value
}
return output
}
function redactAuthHeader(headers: Record<string, string> = {}): Record<string, string> {
return {
...headers,
authorization: '[managed reddit OAuth integration]',
}
}
function requireName(value: string, label: string): string {
const name = value.trim()
if (!name) {
throw new Error(label + ' must be a non-empty string.')
}
return name
}
function stringOrEmpty(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/**
* Call a Reddit OAuth API endpoint on `oauth.reddit.com` using the saved integration.
* @param params.path - Relative API path such as `/api/v1/me` or `/r/programming/hot`.
* @returns Parsed JSON body and response metadata, or a dry-run preview.
* @example
* import request from 'kody:@kody/reddit/request'
* const result = await request({ path: '/r/programming/about' })
* // => { status: 200, body: { data: { display_name: 'programming', ... } }, ... }
*/
export default async function requestEntrypoint(
params: Partial<RedditRequestInput> & Record<string, unknown> = {},
) {
const input = requireRecord(params, 'request')
requireStringField(input, 'path', 'request')
return request(input as RedditRequestInput)
}