Skip to content

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

Package listing

@kody/notify

src/destinations.ts

502 lines · 13.4 KB · TypeScript
import sendSlackMessage from 'kody:@kody/slack/send-message'
import { kody } from 'kody:runtime'
import {
	CONNECT,
	SLACK_CHAT_WRITE_SCOPE,
	assertNever,
	discordBotInstallUrl,
	discordSecretUrl,
	secretPlaceholder,
	slackConnectUrl,
	telegramSecretUrl,
	type Destination,
} from './connect.ts'
import {
	type NotifyChannelConfig,
	destinationReady,
	discordSecretName,
	slackIntegrationName,
	telegramSecretName,
} from './config.ts'

const DISCORD_CONTENT_LIMIT = 2000
const TELEGRAM_TEXT_LIMIT = 4096

export type DestinationStatus = 'sent' | 'preview' | 'skipped' | 'error'

export type DestinationResult = {
	destination: Destination
	status: DestinationStatus
	reason?: string
	connectUrl?: string
	detail?: Record<string, unknown>
}

function secretEntries(
	result: unknown,
): Array<{ name?: string; scope?: string }> {
	if (
		result &&
		typeof result === 'object' &&
		Array.isArray((result as { secrets?: unknown }).secrets)
	) {
		return (result as { secrets: Array<{ name?: string; scope?: string }> })
			.secrets
	}
	return Array.isArray(result) ? result : []
}

export async function listUserSecretNames(): Promise<Set<string>> {
	try {
		const listed = await kody.secret_list({ scope: 'user' })
		const names = new Set<string>()
		for (const entry of secretEntries(listed)) {
			if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
				names.add(entry.name)
			}
		}
		return names
	} catch {
		return new Set()
	}
}

export async function hasSlackIntegration(integration: string): Promise<boolean> {
	try {
		const listed = await kody.integration_list({})
		const integrations =
			listed && typeof listed === 'object' && Array.isArray(listed.integrations)
				? listed.integrations
				: []
		return integrations.some((item) => item?.name === integration)
	} catch {
		return false
	}
}

function errorMessage(error: unknown): string {
	if (error instanceof Error && error.message) return error.message
	return String(error)
}

function clip(text: string, max: number): string {
	if (text.length <= max) return text
	return text.slice(0, max - 1) + '…'
}

async function readJson(response: Response): Promise<unknown> {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text.slice(0, 200)
	}
}

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

function slimHttpError(status: number, body: unknown): Record<string, unknown> {
	const record = asRecord(body)
	return {
		status,
		ok: record.ok ?? false,
		error: record.error ?? record.message ?? record.code ?? null,
		needed: record.needed ?? null,
	}
}

function discordFailureReason(status: number, body: unknown, secretName: string): string {
	const record = asRecord(body)
	const apiMessage =
		typeof record.message === 'string' ? record.message : 'HTTP ' + status
	const code = typeof record.code === 'number' ? record.code : null
	const secretUrl = discordSecretUrl(secretName)
	const installUrl = discordBotInstallUrl()
	if (status === 401) {
		return (
			'Discord bot request failed (401). Confirm `' +
			secretName +
			'` is a bot token (this package sends the Bot prefix) and was not reset. Save or rotate it at ' +
			secretUrl
		)
	}
	if (code === 50001 || /missing access/i.test(apiMessage)) {
		return (
			'Discord bot request failed (403 Missing Access, code 50001): the bot is not in that server or cannot see the channel. Reinstall with View Channel + Send Messages + Read Message History (permissions=68608): ' +
			installUrl +
			'. Original: ' +
			apiMessage
		)
	}
	if (code === 50013 || /missing permissions/i.test(apiMessage)) {
		return (
			'Discord bot request failed (403 Missing Permissions, code 50013): the bot role lacks Send Messages for this channel. Grant Send Messages (and View Channel / Read Message History), then retry. Reinstall helper: ' +
			installUrl +
			'. Original: ' +
			apiMessage
		)
	}
	if (status === 403) {
		return (
			'Discord bot request failed (403): ' +
			apiMessage +
			'. Missing Send Messages (or the bot is not in the server). Reinstall at ' +
			installUrl +
			' and save `' +
			secretName +
			'` at ' +
			secretUrl +
			'.'
		)
	}
	return 'Discord bot request failed (' + status + '): ' + apiMessage + '. Secret setup: ' + secretUrl
}

function telegramFailureReason(status: number, body: unknown, secretName: string): string {
	const record = asRecord(body)
	const description =
		typeof record.description === 'string' ? record.description : 'HTTP ' + status
	const secretUrl = telegramSecretUrl(secretName)
	if (status === 401 || record.error_code === 401) {
		return (
			'Telegram sendMessage failed (401). `' +
			secretName +
			'` is missing or invalid. Create a bot with @BotFather and save the token at ' +
			secretUrl
		)
	}
	if (status === 403 || record.error_code === 403) {
		return (
			'Telegram sendMessage failed (403): ' +
			description +
			'. The bot cannot write to that chat. Have the user start the bot (or add it to the group), then store their numeric chat id with configure.telegram.chatId. Secret setup: ' +
			secretUrl
		)
	}
	if (/chat not found/i.test(description)) {
		return (
			'Telegram sendMessage failed: chat not found. Store the destination chat id with configure.telegram.chatId. Secret: ' +
			secretUrl
		)
	}
	return 'Telegram sendMessage failed (' + status + '): ' + description + '. Secret setup: ' + secretUrl
}

export async function sendEmail(input: {
	subject: string
	text: string
	html?: string
	dryRun: boolean
}): Promise<DestinationResult> {
	if (input.dryRun) {
		return {
			destination: 'email',
			status: 'preview',
			detail: {
				capability: 'email_send',
				to: 'signed-in account email',
				subject: input.subject,
				text: input.text,
				html: Boolean(input.html),
			},
		}
	}

	const payload: { subject: string; text: string; html?: string } = {
		subject: input.subject,
		text: input.text,
	}
	if (input.html) payload.html = input.html
	const sent = await kody.email_send(payload)
	const message =
		sent && typeof sent === 'object' && sent.message && typeof sent.message === 'object'
			? sent.message
			: null
	return {
		destination: 'email',
		status: 'sent',
		detail: {
			id: message && typeof message.id === 'string' ? message.id : null,
			status: sent && typeof sent === 'object' && typeof sent.status === 'string' ? sent.status : null,
		},
	}
}

export async function sendSlack(input: {
	channel: string
	text: string
	dryRun: boolean
	integration: string
}): Promise<DestinationResult> {
	const reconnect = slackConnectUrl(input.integration)
	if (input.dryRun) {
		const preview = await sendSlackMessage({
			channel: input.channel,
			text: input.text,
			dryRun: true,
			integration: input.integration,
		})
		return {
			destination: 'slack',
			status: 'preview',
			detail: {
				helper: 'kody:@kody/slack/send-message',
				integration: input.integration,
				channelConfigured: true,
				requiredScope: SLACK_CHAT_WRITE_SCOPE,
				preview,
			},
		}
	}

	const sent = await sendSlackMessage({
		channel: input.channel,
		text: input.text,
		confirm: true,
		integration: input.integration,
	})
	const record =
		sent && typeof sent === 'object' ? (sent as Record<string, unknown>) : {}
	return {
		destination: 'slack',
		status: 'sent',
		connectUrl: reconnect,
		detail: {
			helper: 'kody:@kody/slack/send-message',
			integration: input.integration,
			channel: typeof record.channel === 'string' ? record.channel : input.channel,
			ts: typeof record.ts === 'string' ? record.ts : null,
		},
	}
}

export async function sendDiscord(input: {
	channelId: string
	text: string
	dryRun: boolean
	secretName: string
}): Promise<DestinationResult> {
	const content = clip(input.text, DISCORD_CONTENT_LIMIT)
	const token = secretPlaceholder(input.secretName)
	const connectUrl = discordSecretUrl(input.secretName)
	if (input.dryRun) {
		const secrets = await listUserSecretNames()
		const secretPresent = secrets.has(input.secretName)
		let auth: Record<string, unknown> | null = null
		if (secretPresent) {
			const response = await fetch('https://discord.com/api/v10/users/@me', {
				headers: { Authorization: 'Bot ' + token },
			})
			const body = await readJson(response)
			auth = response.ok
				? { ok: true, bot: true }
				: slimHttpError(response.status, body)
		}
		return {
			destination: 'discord',
			status: 'preview',
			connectUrl,
			detail: {
				channelConfigured: true,
				content,
				secretName: input.secretName,
				secretPresent,
				auth,
			},
		}
	}

	const response = await fetch(
		'https://discord.com/api/v10/channels/' + encodeURIComponent(input.channelId) + '/messages',
		{
			method: 'POST',
			headers: {
				Authorization: 'Bot ' + token,
				'content-type': 'application/json',
				'User-Agent': 'Kody (@kody/notify)',
			},
			body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
		},
	)
	const body = await readJson(response)
	if (!response.ok) {
		return {
			destination: 'discord',
			status: 'error',
			reason: discordFailureReason(response.status, body, input.secretName),
			connectUrl,
			detail: slimHttpError(response.status, body),
		}
	}
	const record = asRecord(body)
	return {
		destination: 'discord',
		status: 'sent',
		detail: {
			id: typeof record.id === 'string' ? record.id : null,
			channelId: input.channelId,
			secretName: input.secretName,
		},
	}
}

export async function sendTelegram(input: {
	chatId: string
	text: string
	dryRun: boolean
	secretName: string
}): Promise<DestinationResult> {
	const text = clip(input.text, TELEGRAM_TEXT_LIMIT)
	const token = secretPlaceholder(input.secretName)
	const connectUrl = telegramSecretUrl(input.secretName)
	if (input.dryRun) {
		const secrets = await listUserSecretNames()
		const secretPresent = secrets.has(input.secretName)
		let auth: Record<string, unknown> | null = null
		if (secretPresent) {
			const response = await fetch('https://api.telegram.org/bot' + token + '/getMe')
			const body = await readJson(response)
			const record = asRecord(body)
			auth = response.ok && record.ok === true
				? { ok: true, bot: true }
				: slimHttpError(response.status, body)
		}
		return {
			destination: 'telegram',
			status: 'preview',
			connectUrl,
			detail: {
				chatConfigured: true,
				text,
				secretName: input.secretName,
				secretPresent,
				auth,
			},
		}
	}

	const response = await fetch('https://api.telegram.org/bot' + token + '/sendMessage', {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ chat_id: input.chatId, text }),
	})
	const body = await readJson(response)
	const record = asRecord(body)
	if (!response.ok || record.ok !== true) {
		return {
			destination: 'telegram',
			status: 'error',
			reason: telegramFailureReason(response.status, body, input.secretName),
			connectUrl,
			detail: slimHttpError(response.status, body),
		}
	}
	const message = asRecord(record.result)
	return {
		destination: 'telegram',
		status: 'sent',
		detail: {
			messageId: typeof message.message_id === 'number' ? message.message_id : null,
			chatId: input.chatId,
			secretName: input.secretName,
		},
	}
}

export function destinationConnectUrl(
	destination: Destination,
	config: NotifyChannelConfig,
): string | undefined {
	switch (destination) {
		case 'email':
			return undefined
		case 'slack':
			return slackConnectUrl(slackIntegrationName(config))
		case 'discord':
			return discordSecretUrl(discordSecretName(config))
		case 'telegram':
			return telegramSecretUrl(telegramSecretName(config))
		default:
			return assertNever(destination)
	}
}

export async function runDestination(input: {
	destination: Destination
	config: NotifyChannelConfig
	subject: string
	text: string
	html?: string
	dryRun: boolean
}): Promise<DestinationResult> {
	const readiness = destinationReady(input.destination, input.config)
	const connectUrl = destinationConnectUrl(input.destination, input.config)
	if (!readiness.ready) {
		return {
			destination: input.destination,
			status: 'skipped',
			reason: readiness.missing ?? 'destination is not configured',
			connectUrl,
		}
	}

	try {
		switch (input.destination) {
			case 'email':
				return await sendEmail({
					subject: input.subject,
					text: input.text,
					html: input.html,
					dryRun: input.dryRun,
				})
			case 'slack':
				return await sendSlack({
					channel: input.config.slack?.channel as string,
					text: input.text,
					dryRun: input.dryRun,
					integration: slackIntegrationName(input.config),
				})
			case 'discord':
				return await sendDiscord({
					channelId: input.config.discord?.channelId as string,
					text: input.text,
					dryRun: input.dryRun,
					secretName: discordSecretName(input.config),
				})
			case 'telegram':
				return await sendTelegram({
					chatId: input.config.telegram?.chatId as string,
					text: input.text,
					dryRun: input.dryRun,
					secretName: telegramSecretName(input.config),
				})
			default:
				return assertNever(input.destination)
		}
	} catch (error) {
		let reason = errorMessage(error)
		if (input.destination === 'slack') {
			const needed = /needed=([a-z:_]+)/i.exec(reason)?.[1] || SLACK_CHAT_WRITE_SCOPE
			if (/missing_scope|insufficient_scope|invalid_scope|403/i.test(reason) && !/chat:write/.test(reason)) {
				reason =
					reason +
					' Missing user-token scope: ' +
					needed +
					'. Add it under User Token Scopes (or built-in Change scopes), then reconnect at ' +
					(connectUrl ?? CONNECT.slack.url) +
					'.'
			}
		}
		return {
			destination: input.destination,
			status: 'error',
			reason,
			connectUrl,
		}
	}
}