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> { 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, ): 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 { 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, } }