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()