Skip to content

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

Package listing

@kody/notify

src/config.ts

262 lines · 7.5 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import {
	assertNever,
	DESTINATIONS,
	type Destination,
	isDestination,
} from './connect.ts'

export const CONFIG_KEY = 'channels'

export type EmailChannelConfig = {
	enabled?: boolean
}

export type SlackChannelConfig = {
	enabled?: boolean
	/** Slack conversation id for the *fork owner's* destination. Never copy another account's channel. */
	channel?: string
	/** Kody OAuth connection name. Default `slack`. Use `slack-work` for a second workspace. */
	integration?: string
}

export type DiscordChannelConfig = {
	enabled?: boolean
	/** Discord channel snowflake for the *fork owner's* destination. Never copy another account's channel. */
	channelId?: string
	/** Bot-token secret name. Default `discordBotToken`. */
	secretName?: string
}

export type TelegramChannelConfig = {
	enabled?: boolean
	/** Telegram chat id for the *fork owner's* destination. Never copy another account's chat. */
	chatId?: string
	/** Bot-token secret name. Default `telegramBotToken`. */
	secretName?: string
}

export type NotifyChannelConfig = {
	email?: EmailChannelConfig
	slack?: SlackChannelConfig
	discord?: DiscordChannelConfig
	telegram?: TelegramChannelConfig
}

function asRecord(value: unknown): Record<string, unknown> {
	return value && typeof value === 'object' && !Array.isArray(value)
		? (value as Record<string, unknown>)
		: {}
}

function optionalBoolean(value: unknown): boolean | undefined {
	if (value === undefined) return undefined
	if (typeof value !== 'boolean') {
		throw new Error('enabled must be a boolean when provided.')
	}
	return value
}

function optionalTrimmed(value: unknown, field: string): string | undefined {
	if (value === undefined) return undefined
	if (typeof value !== 'string' || value.trim() === '') {
		throw new Error(`${field} must be a non-empty string when provided.`)
	}
	return value.trim()
}

function parseEmail(raw: unknown): EmailChannelConfig | undefined {
	if (raw === undefined) return undefined
	const input = asRecord(raw)
	return { enabled: optionalBoolean(input.enabled) }
}

function parseSlack(raw: unknown): SlackChannelConfig | undefined {
	if (raw === undefined) return undefined
	const input = asRecord(raw)
	return {
		enabled: optionalBoolean(input.enabled),
		channel: optionalTrimmed(input.channel, 'slack.channel'),
		integration: optionalTrimmed(input.integration, 'slack.integration'),
	}
}

function parseDiscord(raw: unknown): DiscordChannelConfig | undefined {
	if (raw === undefined) return undefined
	const input = asRecord(raw)
	return {
		enabled: optionalBoolean(input.enabled),
		channelId: optionalTrimmed(input.channelId, 'discord.channelId'),
		secretName: optionalTrimmed(input.secretName, 'discord.secretName'),
	}
}

function parseTelegram(raw: unknown): TelegramChannelConfig | undefined {
	if (raw === undefined) return undefined
	const input = asRecord(raw)
	return {
		enabled: optionalBoolean(input.enabled),
		chatId: optionalTrimmed(input.chatId, 'telegram.chatId'),
		secretName: optionalTrimmed(input.secretName, 'telegram.secretName'),
	}
}

export function parseChannelConfig(raw: unknown): NotifyChannelConfig {
	const input = asRecord(raw)
	return {
		email: parseEmail(input.email),
		slack: parseSlack(input.slack),
		discord: parseDiscord(input.discord),
		telegram: parseTelegram(input.telegram),
	}
}

export function mergeChannelConfig(
	current: NotifyChannelConfig,
	patch: NotifyChannelConfig,
): NotifyChannelConfig {
	return {
		email: patch.email ? { ...current.email, ...patch.email } : current.email,
		slack: patch.slack ? { ...current.slack, ...patch.slack } : current.slack,
		discord: patch.discord
			? { ...current.discord, ...patch.discord }
			: current.discord,
		telegram: patch.telegram
			? { ...current.telegram, ...patch.telegram }
			: current.telegram,
	}
}

function parseStored(raw: unknown): NotifyChannelConfig {
	if (!raw) return {}
	try {
		const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
		return parseChannelConfig(parsed)
	} catch {
		return {}
	}
}

export async function readChannelConfig(): Promise<NotifyChannelConfig> {
	try {
		const store = packageStorage()
		return parseStored(await store.get(CONFIG_KEY))
	} catch {
		return {}
	}
}

export async function writeChannelConfig(
	config: NotifyChannelConfig,
): Promise<NotifyChannelConfig> {
	try {
		const store = packageStorage()
		await store.set(CONFIG_KEY, JSON.stringify(config))
		return config
	} catch {
		throw new Error(
			'Could not write notify channel config. Live @kody/notify storage is the platform package bucket. Fork this package (or packages.invoke your copy) so packageStorage belongs to the user, then save their destinations — never Kent or official Kody channel ids.',
		)
	}
}

export function emailEnabled(config: NotifyChannelConfig): boolean {
	return config.email?.enabled !== false
}

export function slackIntegrationName(config: NotifyChannelConfig): string {
	return config.slack?.integration || 'slack'
}

export function discordSecretName(config: NotifyChannelConfig): string {
	return config.discord?.secretName || 'discordBotToken'
}

export function telegramSecretName(config: NotifyChannelConfig): string {
	return config.telegram?.secretName || 'telegramBotToken'
}

export function destinationReady(
	destination: Destination,
	config: NotifyChannelConfig,
): { enabled: boolean; ready: boolean; missing: string | null } {
	switch (destination) {
		case 'email': {
			const enabled = emailEnabled(config)
			return { enabled, ready: enabled, missing: enabled ? null : 'email disabled in packageStorage' }
		}
		case 'slack': {
			const enabled = config.slack?.enabled === true
			const channel = config.slack?.channel
			if (!enabled) {
				return { enabled: false, ready: false, missing: 'slack.enabled is not true' }
			}
			if (!channel) {
				return {
					enabled: true,
					ready: false,
					missing: 'slack.channel is not set in packageStorage',
				}
			}
			return { enabled: true, ready: true, missing: null }
		}
		case 'discord': {
			const enabled = config.discord?.enabled === true
			const channelId = config.discord?.channelId
			if (!enabled) {
				return { enabled: false, ready: false, missing: 'discord.enabled is not true' }
			}
			if (!channelId) {
				return {
					enabled: true,
					ready: false,
					missing: 'discord.channelId is not set in packageStorage',
				}
			}
			return { enabled: true, ready: true, missing: null }
		}
		case 'telegram': {
			const enabled = config.telegram?.enabled === true
			const chatId = config.telegram?.chatId
			if (!enabled) {
				return { enabled: false, ready: false, missing: 'telegram.enabled is not true' }
			}
			if (!chatId) {
				return {
					enabled: true,
					ready: false,
					missing: 'telegram.chatId is not set in packageStorage',
				}
			}
			return { enabled: true, ready: true, missing: null }
		}
		default:
			return assertNever(destination)
	}
}

export function resolveDestinations(
	requested: unknown,
	config: NotifyChannelConfig,
): Destination[] {
	if (requested === undefined) {
		return DESTINATIONS.filter((destination) => {
			return destinationReady(destination, config).enabled
		})
	}
	if (!Array.isArray(requested) || requested.length === 0) {
		throw new Error(
			'channels must be a non-empty array of email, slack, discord, and/or telegram.',
		)
	}
	const selected: Destination[] = []
	for (const value of requested) {
		if (!isDestination(value)) {
			throw new Error(
				`Unknown channel ${JSON.stringify(value)}. Use email, slack, discord, or telegram.`,
			)
		}
		if (!selected.includes(value)) selected.push(value)
	}
	return selected
}