Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/discord

src/validation.ts

108 lines · 4.0 KB · TypeScript
export type InputRecord = Record<string, unknown>
export type DiscordLane = 'bot' | 'oauth'

export const DEFAULT_BOT_SECRET_NAME = 'discordBotToken'
export const DEFAULT_OAUTH_INTEGRATION = 'discord'

export function inputRecord(value: unknown): InputRecord {
	if (value === null || typeof value !== 'object' || Array.isArray(value)) {
		throw new Error('Input must be an object.')
	}
	return value as InputRecord
}

export function pickString(...values: unknown[]): string | null {
	for (const value of values) {
		if (typeof value === 'string' && value.trim().length > 0) return value.trim()
	}
	return null
}

export function requiredString(input: InputRecord, key: string): string {
	const value = input[key]
	if (typeof value !== 'string' || value.trim().length === 0) {
		throw new Error(key + ' is required.')
	}
	return value.trim()
}

export function optionalString(input: InputRecord, key: string): string | undefined {
	const value = input[key]
	if (value === undefined || value === null || value === '') return undefined
	if (typeof value !== 'string') throw new Error(key + ' must be a string.')
	const trimmed = value.trim()
	return trimmed.length > 0 ? trimmed : undefined
}

export function optionalBoolean(input: InputRecord, key: string): boolean | undefined {
	const value = input[key]
	if (value === undefined || value === null) return undefined
	if (typeof value !== 'boolean') throw new Error(key + ' must be a boolean.')
	return value
}

export function snowflake(input: InputRecord, key: string, aliases: string[] = []): string {
	const value = pickString(input[key], ...aliases.map((alias) => input[alias]))
	if (!value) throw new Error(key + ' is required.')
	if (!/^[0-9]{5,32}$/.test(value)) {
		throw new Error(key + ' must be a Discord snowflake id.')
	}
	return value
}

export function optionalSnowflake(input: InputRecord, key: string, aliases: string[] = []): string | undefined {
	const value = pickString(input[key], ...aliases.map((alias) => input[alias]))
	if (!value) return undefined
	if (!/^[0-9]{5,32}$/.test(value)) {
		throw new Error(key + ' must be a Discord snowflake id.')
	}
	return value
}

export function boundedInteger(value: unknown, fallback: number, min: number, max: number, key = 'limit'): number {
	if (value === undefined || value === null || value === '') return fallback
	if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
		throw new Error(key + ' must be an integer from ' + min + ' through ' + max + '.')
	}
	return value
}

export function secretName(input: InputRecord): string {
	const value = optionalString(input, 'secretName') ?? DEFAULT_BOT_SECRET_NAME
	if (!/^[A-Za-z][A-Za-z0-9_-]{0,127}$/.test(value)) {
		throw new Error('secretName must be a Kody secret name such as discordBotToken.')
	}
	return value
}

export function integrationName(input: InputRecord): string {
	const value = optionalString(input, 'integration') ?? DEFAULT_OAUTH_INTEGRATION
	if (!/^[a-z][a-z0-9._-]{0,63}$/.test(value)) {
		throw new Error('integration must be a Kody integration name such as discord or discord-work.')
	}
	return value
}

export function requiredLane(input: InputRecord, fallback: DiscordLane = 'bot'): DiscordLane {
	const value = input.lane
	if (value === undefined || value === null || value === '') return fallback
	if (value === 'bot' || value === 'oauth') return value
	throw new Error("lane must be 'bot' or 'oauth'.")
}

export function clipText(value: string, maxLength: number): string {
	const source = value.replace(/\s+/g, ' ').trim()
	if (!source) return ''
	return source.length <= maxLength ? source : source.slice(0, maxLength - 3).trimEnd() + '...'
}

export function requireLiveMutation(input: InputRecord, action: string): 'dryRun' | 'live' {
	if (input.dryRun === true) return 'dryRun'
	if (input.confirm !== true) {
		throw new Error(
			action +
				' requires confirm: true after explicit approval of the destination and content. Pass dryRun: true to preview without calling Discord.',
		)
	}
	return 'live'
}