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,
}
}