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() /** * Call a Reddit OAuth API endpoint on `oauth.reddit.com` using the saved integration. * Prefer this escape hatch when a dedicated Reddit helper does not exist. * * @example * import request from 'kody:@kody/reddit/request' * const result = await request({ path: '/r/programming/about' }) */ 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 { 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 body?: unknown integration?: string } = {}, ): Promise { 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 body?: unknown integration?: string } = {}, ): Promise { 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( path: string, options: { method?: string query?: QueryInput headers?: Record body?: unknown integration?: string } = {}, ): Promise { const result = await redditRequestWithResponse(path, options) return result.body as T } /** * Call a Reddit OAuth API endpoint on `oauth.reddit.com` using the saved integration. * Prefer this named helper when you already have a typed `RedditRequestInput`. * * @param input - Path, method, query, body, and optional integration alias. * @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' }) */ export async function request( input: RedditRequestInput, ): Promise { 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 { 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 { const selected = [ 'content-type', 'x-ratelimit-used', 'x-ratelimit-remaining', 'x-ratelimit-reset', ] const output: Record = {} for (const key of selected) { const value = headers.get(key) if (value) output[key] = value } return output } function redactAuthHeader(headers: Record = {}): Record { 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 & Record = {}, ) { const input = requireRecord(params, 'request') requireStringField(input, 'path', 'request') return request(input as RedditRequestInput) }