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