Skip to content
← Public packages

@kentcdodds/exchange-threads

Open kody.exchange threads from Kody. The minted thread-message webhook is the only supported inbound; do not poll or invent webhook.site.

src/reply-input.ts

110 lines · 2.7 KB · TypeScript
import {
	ValidationError,
	any,
	boolean,
	object,
	optional,
	parse,
	string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'

export type NormalizedReplyInput = {
	threadId: string
	body: unknown
	token?: string
	dryRun?: boolean
}

const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')

const KNOWN_KEYS = [
	'threadId',
	'thread_id',
	'thread',
	'id',
	'body',
	'message',
	'text',
	'content',
	'token',
	'dryRun',
] as const

const HINT =
	`Pass threadId (aliases: thread_id, thread, id) and body (aliases: message, text, content). ` +
	`Optional token for guest threads. dryRun: true previews without sending. Unknown keys are rejected.`

const Schema = object(
	{
		threadId: optional(nonEmptyString),
		thread_id: optional(nonEmptyString),
		thread: optional(nonEmptyString),
		id: optional(nonEmptyString),
		body: optional(any()),
		message: optional(any()),
		text: optional(any()),
		content: optional(any()),
		token: optional(nonEmptyString),
		dryRun: optional(boolean()),
	},
	{ unknownKeys: 'error' },
).transform((raw): Omit<NormalizedReplyInput, 'threadId' | 'body'> & {
	threadId?: string
	body?: unknown
} => {
	const threadIds = [raw.threadId, raw.thread_id, raw.thread, raw.id].filter(
		(v): v is string => typeof v === 'string' && v.trim().length > 0,
	)
	if (new Set(threadIds).size > 1) {
		throw new Error('Conflicting thread ids — threadId / thread_id / thread / id must agree')
	}
	const bodies = [
		['body', raw.body],
		['message', raw.message],
		['text', raw.text],
		['content', raw.content],
	].filter(([, v]) => v !== undefined)
	if (bodies.length > 1) {
		// allow identical values
		const serialized = new Set(bodies.map(([, v]) => JSON.stringify(v)))
		if (serialized.size > 1) {
			throw new Error('Conflicting body aliases — body / message / text / content must agree')
		}
	}
	return {
		threadId: threadIds[0],
		body: bodies[0]?.[1],
		token: raw.token,
		dryRun: raw.dryRun,
	}
})

export function parseReplyInput(input: unknown): NormalizedReplyInput {
	let normalized: Omit<NormalizedReplyInput, 'threadId' | 'body'> & {
		threadId?: string
		body?: unknown
	}
	try {
		normalized = parse(Schema, input ?? {})
	} catch (error) {
		if (error instanceof ValidationError) {
			throw formatSchemaValidationError(error, {
				surface: 'reply',
				knownKeys: KNOWN_KEYS,
				hint: HINT,
				input,
			})
		}
		throw error
	}
	if (!normalized.threadId) {
		throw new Error(
			'reply requires threadId (aliases: thread_id, thread, id).',
		)
	}
	if (normalized.body === undefined) {
		throw new Error('reply requires body (aliases: message, text, content).')
	}
	return normalized as NormalizedReplyInput
}