Skip to content

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

Package listing

@kody/intercom

src/setup.ts

346 lines · 11.0 KB · TypeScript
import type {
	DryRunResult,
	IntercomAuthInput,
	IntercomAuthMode,
	IntercomRegion,
	JsonRecord,
} from './types.ts'
import { optionalString, requireString } from './types.ts'

export const INTERCOM_API_VERSION = '2.14'
export const INTERCOM_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const INTERCOM_TOKEN_URL = 'https://api.intercom.io/auth/eagle/token'
export const INTERCOM_DEVELOPER_HUB_URL = 'https://app.intercom.com/developers'
export const INTERCOM_OAUTH_DOCS_URL =
	'https://developers.intercom.com/docs/build-an-integration/learn-more/authentication/setting-up-oauth'
export const INTERCOM_ACCESS_TOKEN_DOCS_URL =
	'https://developers.intercom.com/docs/build-an-integration/learn-more/authentication'
export const INTERCOM_API_DOCS_URL = 'https://developers.intercom.com/docs/references/rest-api/api.intercom.io'
export const DEFAULT_INTEGRATION_NAME = 'intercom'
export const DEFAULT_TOKEN_SECRET = 'intercomAccessToken'
export const DEFAULT_REGION: IntercomRegion = 'us'

export const INTERCOM_REGIONS = {
	us: {
		region: 'us',
		apiBaseUrl: 'https://api.intercom.io',
		authorizeUrl: 'https://app.intercom.com/oauth',
		apiHost: 'api.intercom.io',
	},
	eu: {
		region: 'eu',
		apiBaseUrl: 'https://api.eu.intercom.io',
		authorizeUrl: 'https://app.eu.intercom.com/oauth',
		apiHost: 'api.eu.intercom.io',
	},
	au: {
		region: 'au',
		apiBaseUrl: 'https://api.au.intercom.io',
		authorizeUrl: 'https://app.au.intercom.com/oauth',
		apiHost: 'api.au.intercom.io',
	},
} as const satisfies Record<
	IntercomRegion,
	{ region: IntercomRegion; apiBaseUrl: string; authorizeUrl: string; apiHost: string }
>

export const INTERCOM_REQUIRED_HOSTS = [
	'api.intercom.io',
	'api.eu.intercom.io',
	'api.au.intercom.io',
] as const

const ALLOWED_HOSTS = new Set<string>(INTERCOM_REQUIRED_HOSTS)

export const INTERCOM_PERMISSIONS = {
	'Read and list users and companies': 'List contacts and related people data.',
	'Read and write users': 'Create or update contacts.',
	'Read conversations': 'List, get, and search conversations.',
	'Write conversations': 'Reply to or close conversations.',
	'Read and List articles': 'List and get Help Center articles.',
	'Read and Write Articles': 'Create or update Help Center articles.',
	'Read admins': 'Resolve the connected admin via GET /me.',
} as const

export type IntercomOperation =
	| 'me.read'
	| 'contacts.read'
	| 'contacts.write'
	| 'conversations.read'
	| 'conversations.write'
	| 'articles.read'
	| 'articles.write'
	| 'unknownRead'
	| 'unknownMutation'

export function resolveRegion(input: IntercomAuthInput = {}): IntercomRegion {
	const explicit = optionalString(input.region, 'region')
	if (!explicit) return DEFAULT_REGION
	if (explicit !== 'us' && explicit !== 'eu' && explicit !== 'au') {
		throw new Error('region must be "us", "eu", or "au".')
	}
	return explicit
}

export function resolveApiBaseUrl(input: IntercomAuthInput = {}): string {
	const explicit = optionalString(input.apiBaseUrl, 'apiBaseUrl')
	if (explicit) {
		let parsed: URL
		try {
			parsed = new URL(explicit)
		} catch {
			throw new Error('apiBaseUrl must be an absolute https URL.')
		}
		if (parsed.protocol !== 'https:') {
			throw new Error('apiBaseUrl must use https.')
		}
		if (!ALLOWED_HOSTS.has(parsed.host)) {
			throw new Error(
				`Intercom API hosts are ${INTERCOM_REQUIRED_HOSTS.join(', ')}. Got ${parsed.host}.`,
			)
		}
		return `${parsed.protocol}//${parsed.host}`
	}
	return INTERCOM_REGIONS[resolveRegion(input)].apiBaseUrl
}

export function resolveAuthorizeUrl(input: IntercomAuthInput = {}): string {
	const apiBaseUrl = resolveApiBaseUrl(input)
	const host = new URL(apiBaseUrl).host
	if (host === INTERCOM_REGIONS.eu.apiHost) return INTERCOM_REGIONS.eu.authorizeUrl
	if (host === INTERCOM_REGIONS.au.apiHost) return INTERCOM_REGIONS.au.authorizeUrl
	return INTERCOM_REGIONS.us.authorizeUrl
}

export function resolveIntegrationName(input: IntercomAuthInput = {}): string {
	const explicit =
		optionalString(input.integrationName, 'integrationName') ??
		optionalString(input.integration, 'integration')
	if (explicit) return explicit

	const account = optionalString(input.account, 'account')
	if (!account || account === 'default' || account === 'intercom') {
		return DEFAULT_INTEGRATION_NAME
	}
	if (account.startsWith('intercom-')) return account
	return `intercom-${account}`
}

export function resolveSecretName(input: IntercomAuthInput = {}): string {
	const explicit = optionalString(input.secretName, 'secretName')
	if (explicit) return explicit
	const integrationName = resolveIntegrationName(input)
	if (integrationName === DEFAULT_INTEGRATION_NAME) return DEFAULT_TOKEN_SECRET
	const suffix = integrationName.startsWith('intercom-')
		? integrationName.slice('intercom-'.length)
		: integrationName
	return `${DEFAULT_TOKEN_SECRET}-${suffix}`
}

export function oauthConnectUrl(
	provider: string = DEFAULT_INTEGRATION_NAME,
	input: IntercomAuthInput = {},
): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: resolveAuthorizeUrl(input),
		tokenUrl: INTERCOM_TOKEN_URL,
		apiBaseUrl: resolveApiBaseUrl(input),
		flow: 'confidential',
		pkce: 'false',
		allowedHosts: INTERCOM_REQUIRED_HOSTS.join(','),
		dashboardUrl: INTERCOM_OAUTH_DOCS_URL,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function accessTokenSetupUrl(secretName: string = DEFAULT_TOKEN_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Intercom workspace access token',
		allowedHosts: INTERCOM_REQUIRED_HOSTS.join(','),
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
	region?: IntercomRegion
}): string {
	const connect = oauthConnectUrl(options.integrationName, { region: options.region })
	return [
		'Intercom credentials are missing.',
		'Access token (fastest for your own workspace): create a private app in the Intercom Developer Hub,',
		'then save the token (do not paste the value in chat):',
		accessTokenSetupUrl(options.secretName),
		INTERCOM_ACCESS_TOKEN_DOCS_URL,
		'OAuth (required when accessing another workspace): create a public Intercom app,',
		`register redirect URI ${INTERCOM_CALLBACK_URL}, then connect:`,
		connect,
		INTERCOM_OAUTH_DOCS_URL,
		`Required API hosts: ${INTERCOM_REQUIRED_HOSTS.join(', ')}.`,
	].join(' ')
}

export function permissionForOperation(operation: IntercomOperation): string {
	switch (operation) {
		case 'me.read':
			return 'Read admins'
		case 'contacts.read':
			return 'Read and list users and companies'
		case 'contacts.write':
			return 'Read and write users'
		case 'conversations.read':
			return 'Read conversations'
		case 'conversations.write':
			return 'Write conversations'
		case 'articles.read':
			return 'Read and List articles'
		case 'articles.write':
			return 'Read and Write Articles'
		case 'unknownRead':
			return 'the matching Intercom permission for this path'
		case 'unknownMutation':
			return 'the matching Intercom write permission for this path'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Intercom operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForOperation(
	operation: IntercomOperation,
	options: {
		authMode: IntercomAuthMode
		integrationName: string
		secretName: string
	},
): string {
	const permission = permissionForOperation(operation)
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and enable "${permission}" on the Intercom app.`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL: ${oauthConnectUrl(options.integrationName)}`,
				`Permission reference: ${INTERCOM_OAUTH_DOCS_URL}`,
			].join(' ')
		case 'accessToken':
			return [
				`Create a private Intercom app with "${permission}", then save the token as ${options.secretName}.`,
				INTERCOM_ACCESS_TOKEN_DOCS_URL,
				accessTokenSetupUrl(options.secretName),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Intercom auth mode: ${String(exhaustive)}`)
		}
	}
}

export function isMutatingMethod(method: string): boolean {
	const normalized = method.toUpperCase()
	return (
		normalized === 'POST' ||
		normalized === 'PUT' ||
		normalized === 'PATCH' ||
		normalized === 'DELETE'
	)
}

export function isSearchPath(path: string): boolean {
	return /\/search(?:\/|$|\?)/.test(path)
}

export function inferOperationFromPath(
	method: string,
	path: string,
	explicit?: IntercomOperation,
): IntercomOperation {
	if (explicit) return explicit
	const mutating = isMutatingMethod(method) && !isSearchPath(path)
	if (/(?:^|\/)me(?:\/|$|\?)/.test(path)) {
		return mutating ? 'unknownMutation' : 'me.read'
	}
	if (/\/contacts(?:\/|$|\?)/.test(path)) {
		return mutating ? 'contacts.write' : 'contacts.read'
	}
	if (/\/conversations(?:\/|$|\?)/.test(path)) {
		return mutating ? 'conversations.write' : 'conversations.read'
	}
	if (/\/articles(?:\/|$|\?)/.test(path)) {
		return mutating ? 'articles.write' : 'articles.read'
	}
	return mutating ? 'unknownMutation' : 'unknownRead'
}

export function normalizeIntercomPath(path: string): string {
	const trimmed = requireString(path, 'path')
	if (trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (!ALLOWED_HOSTS.has(url.host)) {
			throw new Error(
				`Intercom requests must use one of ${INTERCOM_REQUIRED_HOSTS.join(', ')}. Got ${url.host}.`,
			)
		}
		return `${url.pathname}${url.search}`
	}
	return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}

export function intercomUrl(
	path: string,
	query?: JsonRecord,
	input: IntercomAuthInput = {},
): string {
	const normalized = normalizeIntercomPath(path)
	const url = new URL(normalized, `${resolveApiBaseUrl(input)}/`)
	if (query) {
		for (const [key, value] of Object.entries(query)) {
			if (value === undefined || value === null) continue
			if (typeof value === 'boolean' || typeof value === 'number') {
				url.searchParams.set(key, String(value))
				continue
			}
			if (typeof value === 'string') {
				url.searchParams.set(key, value)
				continue
			}
			throw new Error(`query.${key} must be a string, number, or boolean.`)
		}
	}
	return url.toString()
}

export function mutationPreview(
	input: { confirm?: boolean; dryRun?: boolean },
	preview: { method: string; path: string; url: string; body?: JsonRecord },
): DryRunResult | null {
	if (!input.dryRun) {
		if (input.confirm !== true) {
			throw new Error(
				`${preview.method} ${preview.path} requires confirm: true after explicit user approval, or dryRun: true.`,
			)
		}
		return null
	}
	return {
		dryRun: true,
		method: preview.method,
		path: preview.path,
		url: preview.url,
		body: preview.body,
	}
}

export const OAUTH_CONNECT_URL = oauthConnectUrl()
export const ACCESS_TOKEN_SETUP_URL = accessTokenSetupUrl()