Skip to content
← Public packages

@kody/slack

Read Slack conversations and safely send messages as the authorizing user through the saved slack OAuth integration.

src/request.ts

199 lines · 6.5 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'

export const SLACK_API_ORIGIN = 'https://slack.com'
export const SLACK_INTEGRATION = 'slack'
export const SLACK_CONNECT_ORIGIN = 'https://kody.codes'
export const SLACK_CALLBACK_URL = SLACK_CONNECT_ORIGIN + '/connect/oauth'
export const SLACK_AUTHORIZE_URL = 'https://slack.com/oauth/v2_user/authorize'
export const SLACK_TOKEN_URL = 'https://slack.com/api/oauth.v2.user.access'
export const SLACK_API_BASE_URL = 'https://slack.com/api'
export const SLACK_DASHBOARD_URL = 'https://api.slack.com/apps'
export const SLACK_ALLOWED_HOSTS = ['slack.com', 'files.slack.com'] as const

export const SLACK_USER_SCOPES = [
	'chat:write',
	'channels:history',
	'channels:read',
	'groups:history',
	'groups:read',
	'im:history',
	'im:read',
	'mpim:history',
	'mpim:read',
	'users:read',
] as const

const METHOD_SCOPES: Record<string, readonly string[]> = {
	'auth.test': [],
	'chat.postMessage': ['chat:write'],
	'conversations.history': ['channels:history', 'groups:history', 'im:history', 'mpim:history'],
	'conversations.replies': ['channels:history', 'groups:history', 'im:history', 'mpim:history'],
	'users.conversations': ['channels:read', 'groups:read', 'im:read', 'mpim:read'],
	'users.list': ['users:read'],
}

export type SlackResponse = {
	ok: boolean
	error?: string
	needed?: string
	provided?: string
	response_metadata?: { next_cursor?: string }
	[key: string]: unknown
}

export class SlackApiError extends Error {
	readonly method: string
	readonly status: number
	readonly details: SlackResponse
	readonly integration: string
	readonly needed: string | null

	constructor(method: string, status: number, details: SlackResponse, integration: string) {
		const needed = missingScope(details, method)
		super(formatSlackError(method, status, details, integration, needed))
		this.name = 'SlackApiError'
		this.method = method
		this.status = status
		this.details = details
		this.integration = integration
		this.needed = needed
	}
}

const authenticatedFetches = new Map<string, typeof fetch>()

export function resolveIntegrationName(value: unknown): string {
	if (value === undefined || value === null || value === '') return SLACK_INTEGRATION
	if (typeof value !== 'string' || value.trim().length === 0) {
		throw new Error('integration must be a non-empty string (Kody OAuth connection name).')
	}
	return value.trim()
}

export function connectOauthUrl(integration: string): string {
	return SLACK_CALLBACK_URL + '?provider=' + encodeURIComponent(integration)
}

/** Reconnect URL for an existing Slack connection. */
export function reconnectUrl(integration: string = SLACK_INTEGRATION): string {
	return connectOauthUrl(integration)
}

/** Prefilled user-token connect URL (confidential, comma-separated scopes). */
export function byoConnectUrl(integration: string = SLACK_INTEGRATION): string {
	const url = new URL(SLACK_CALLBACK_URL)
	url.searchParams.set('provider', integration)
	url.searchParams.set('authorizeUrl', SLACK_AUTHORIZE_URL)
	url.searchParams.set('tokenUrl', SLACK_TOKEN_URL)
	url.searchParams.set('apiBaseUrl', SLACK_API_BASE_URL)
	url.searchParams.set('flow', 'confidential')
	url.searchParams.set('tokenExchangeStyle', 'form')
	url.searchParams.set('scopeSeparator', ',')
	url.searchParams.set('allowedHosts', SLACK_ALLOWED_HOSTS.join(','))
	url.searchParams.set('dashboardUrl', SLACK_DASHBOARD_URL)
	url.searchParams.set('scopes', SLACK_USER_SCOPES.join(','))
	return url.toString()
}

export async function slackRequest<T extends SlackResponse>(
	method: string,
	params: Record<string, unknown> = {},
	integration: string = SLACK_INTEGRATION,
): Promise<T> {
	if (!/^[a-z][a-z0-9_.]+$/.test(method)) {
		throw new Error('Invalid Slack Web API method name.')
	}

	const body = new URLSearchParams()
	for (const [key, value] of Object.entries(params)) {
		if (value === undefined || value === null || value === '') continue
		body.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value))
	}

	const url = new URL('/api/' + method, SLACK_API_ORIGIN)
	const authedFetch = await getAuthenticatedFetch(integration)
	const response = await authedFetch(url.toString(), {
		method: 'POST',
		headers: {
			accept: 'application/json',
			'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
		},
		body,
	})
	const result = (await response.json()) as SlackResponse

	if (!response.ok || result.ok !== true) {
		throw new SlackApiError(method, response.status, result, integration)
	}

	return result as T
}

export function nextCursor(response: SlackResponse): string | null {
	const cursor = response.response_metadata?.next_cursor
	return typeof cursor === 'string' && cursor.length > 0 ? cursor : null
}

async function getAuthenticatedFetch(integration: string): Promise<typeof fetch> {
	const cached = authenticatedFetches.get(integration)
	if (cached) return cached
	const created = await createAuthenticatedFetch(integration)
	authenticatedFetches.set(integration, created)
	return created
}

function missingScope(details: SlackResponse, method: string): string | null {
	if (typeof details.needed === 'string' && details.needed.trim().length > 0) {
		return details.needed.trim()
	}
	const mapped = METHOD_SCOPES[method]
	if (mapped && mapped.length > 0) return mapped.join(', ')
	return null
}

function formatSlackError(
	method: string,
	status: number,
	details: SlackResponse,
	integration: string,
	needed: string | null,
): string {
	const error = details.error || (status === 403 ? 'insufficient_scope' : String(status))
	const reconnect = connectOauthUrl(integration)
	const scopeHint = needed
		? ' Missing user-token scope: ' + needed + '.'
		: ' A required user-token scope is missing.'
	const byoHint =
		' Next: add that scope under User Token Scopes in the Slack app, then reconnect at ' +
		reconnect +
		'. This package cannot use bot tokens.'

	switch (error) {
		case 'missing_scope':
		case 'invalid_scope':
		case 'insufficient_scope':
			return 'Slack API ' + method + ' failed: ' + error + '.' + scopeHint + byoHint
		case 'invalid_auth':
		case 'token_revoked':
		case 'not_authed':
		case 'account_inactive':
			return (
				'Slack API ' +
				method +
				' failed: ' +
				error +
				'. Reconnect the ' +
				integration +
				' OAuth integration at ' +
				reconnect +
				' user-token Slack app. Then re-run kody:@kody/slack/smoke-test.'
			)
		default: {
			if (status === 403) {
				return 'Slack API ' + method + ' failed with HTTP 403 (' + error + ').' + scopeHint + byoHint
			}
			const suffix = needed ? ' Slack also reported needed=' + needed + '.' : ''
			return 'Slack API ' + method + ' failed: ' + error + '.' + suffix
		}
	}
}