← 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 · TypeScriptimport { 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
}
}
}