← 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 · TypeScriptimport {
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
}