Skip to content

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

Package listing

@kody/notify

src/index.ts

225 lines · 5.9 KB · TypeScript
import {
	CONNECT,
	DESTINATIONS,
	type Destination,
} from './connect.ts'
import {
	type NotifyChannelConfig,
	destinationReady,
	discordSecretName,
	mergeChannelConfig,
	parseChannelConfig,
	readChannelConfig,
	resolveDestinations,
	slackIntegrationName,
	telegramSecretName,
	writeChannelConfig,
} from './config.ts'
import {
	hasSlackIntegration,
	listUserSecretNames,
	runDestination,
	type DestinationResult,
} from './destinations.ts'

export type NotifyInput = {
	/** Plain-text body. Required to send or preview a send. */
	text?: string
	/** Email subject; also prefixed onto chat text when provided. */
	subject?: string
	/** Alias for subject. */
	title?: string
	/** Optional HTML body for email only. */
	html?: string
	/** Preview destinations without sending. Also skips writing `configure`. */
	dryRun?: boolean
	/** Limit this call to these destinations. */
	channels?: Destination[]
	/**
	 * Persist destination config in this package's packageStorage.
	 * Live `@kody/notify` storage is the platform bucket — fork first, then
	 * save the *user's* channel/chat ids. Never copy Kent or official Kody
	 * destinations. Pass `dryRun: true` to preview the merge without writing.
	 */
	configure?: NotifyChannelConfig
}

export type NotifyReady = {
	destination: Destination
	enabled: boolean
	ready: boolean
	missing: string | null
	credential: boolean
}

export type NotifyResult = {
	ok: boolean
	dryRun: boolean
	saved: boolean
	subject: string | null
	text: string | null
	results: DestinationResult[]
	config: NotifyChannelConfig
	ready: NotifyReady[]
	connect: typeof CONNECT
	note: string
}

function trimOrNull(value: unknown): string | null {
	if (typeof value !== 'string') return null
	const trimmed = value.trim()
	return trimmed ? trimmed : null
}

function buildSubject(subject: string | null, text: string | null): string {
	if (subject) return subject
	if (text) return text.length > 80 ? text.slice(0, 77) + '…' : text
	return 'Notification'
}

function buildChatText(subject: string | null, text: string | null): string {
	if (subject && text) return subject + '\n\n' + text
	return text ?? subject ?? ''
}

async function credentialMap(
	config: NotifyChannelConfig,
): Promise<Record<Destination, boolean>> {
	const [secrets, slack] = await Promise.all([
		listUserSecretNames(),
		hasSlackIntegration(slackIntegrationName(config)),
	])
	return {
		email: true,
		slack,
		discord: secrets.has(discordSecretName(config)),
		telegram: secrets.has(telegramSecretName(config)),
	}
}

function readiness(
	config: NotifyChannelConfig,
	credentials: Record<Destination, boolean>,
): NotifyReady[] {
	return DESTINATIONS.map((destination) => {
		const state = destinationReady(destination, config)
		let missing = state.missing
		if (state.ready && !credentials[destination]) {
			switch (destination) {
				case 'email':
					missing = null
					break
				case 'slack':
					missing =
						'Slack OAuth integration `' +
						slackIntegrationName(config) +
						'` is not connected'
					break
				case 'discord':
					missing = 'secret `' + discordSecretName(config) + '` is missing'
					break
				case 'telegram':
					missing = 'secret `' + telegramSecretName(config) + '` is missing'
					break
				default: {
					const exhaustive: never = destination
					throw new Error('Unhandled notify destination: ' + String(exhaustive))
				}
			}
		}
		return {
			destination,
			enabled: state.enabled,
			ready: state.ready && credentials[destination],
			missing,
			credential: credentials[destination],
		}
	})
}

/**
 * Fan out a notify-self message to every enabled destination in
 * packageStorage: Kody email, Slack (via `@kody/slack`), Discord, and
 * Telegram. Pass `dryRun: true` to preview without sending or writing
 * `configure`. Pass `configure` to save this fork's destinations — never
 * hard-code another account's channels.
 *
 * @example
 * import notify from 'kody:@kody/notify'
 * const preview = await notify({
 *   subject: 'Deploy finished',
 *   text: 'web@sha shipped.',
 *   dryRun: true,
 * })
 */
export default async function notify(
	input: NotifyInput = {},
): Promise<NotifyResult> {
	const params = input && typeof input === 'object' ? input : {}
	const dryRun = params.dryRun === true
	const text = trimOrNull(params.text)
	const subject = trimOrNull(params.subject) ?? trimOrNull(params.title)
	const html = trimOrNull(params.html) ?? undefined

	let config = await readChannelConfig()
	let saved = false
	if (params.configure !== undefined) {
		const merged = mergeChannelConfig(config, parseChannelConfig(params.configure))
		if (dryRun) {
			config = merged
		} else {
			config = await writeChannelConfig(merged)
			saved = true
		}
	}

	const credentials = await credentialMap(config)
	const ready = readiness(config, credentials)
	const note =
		'Live @kody/notify packageStorage is the platform bucket. Fork (or invoke your copy) before saving destinations. Ask the user for *their* Slack channel, Discord channel, and Telegram chat ids — do not copy Kent or official Kody destinations. Pass dryRun: true until they confirm a live send or config write.'

	if (!text && !subject) {
		return {
			ok: true,
			dryRun: dryRun || !saved,
			saved,
			subject: null,
			text: null,
			results: [],
			config,
			ready,
			connect: CONNECT,
			note,
		}
	}

	const destinations = resolveDestinations(params.channels, config)
	const messageText = buildChatText(subject, text)
	const emailSubject = buildSubject(subject, text)
	const results: DestinationResult[] = []
	for (const destination of destinations) {
		results.push(
			await runDestination({
				destination,
				config,
				subject: emailSubject,
				text: destination === 'email' ? (text ?? messageText) : messageText,
				html,
				dryRun,
			}),
		)
	}

	return {
		ok: results.every((result) => result.status !== 'error'),
		dryRun,
		saved,
		subject: emailSubject,
		text: messageText,
		results,
		config,
		ready,
		connect: CONNECT,
		note,
	}
}