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