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 } 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> { try { const listed = await kody.secretList({ scope: 'user' }) const names = new Set() 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 { try { const listed = await kody.integrationList({}) 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 { 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 { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } function slimHttpError(status: number, body: unknown): Record { 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 { 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.emailSend(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 { 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) : {} 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 { 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 | 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 { 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 | 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 { 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, then reconnect at ' + (connectUrl ?? CONNECT.slack.url) + '.' } } return { destination: input.destination, status: 'error', reason, connectUrl, } } }