Skip to content

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

Package listing

@kody/intercom

src/conversations.ts

285 lines · 8.5 KB · TypeScript
import { parseAuth } from './auth.ts'
import { intercomRequest } from './client.ts'
import { compactDefined, extractListItems, mapConversation } from './models.ts'
import { getMe } from './me.ts'
import type {
	DryRunResult,
	IntercomAuthInput,
	IntercomConversation,
	IntercomPageInfo,
	IntercomSearchQuery,
	JsonRecord,
	MutationInput,
} from './types.ts'
import {
	clampInt,
	optionalBoolean,
	optionalString,
	requireRecord,
	requireString,
} from './types.ts'

export type ListConversationsInput = IntercomAuthInput & {
	perPage?: number
	startingAfter?: string
}

export type GetConversationInput = IntercomAuthInput & {
	id: string
}

export type SearchConversationsInput = IntercomAuthInput & {
	query?: IntercomSearchQuery | JsonRecord
	state?: string
	perPage?: number
	startingAfter?: string
}

export type ReplyConversationInput = IntercomAuthInput &
	MutationInput & {
		id: string
		body: string
		adminId?: string
		messageType?: string
		type?: string
	}

export type CloseConversationInput = IntercomAuthInput &
	MutationInput & {
		id: string
		adminId?: string
	}

export type ConversationListResult = {
	items: Array<IntercomConversation>
	pageInfo: IntercomPageInfo
}

function searchBody(input: SearchConversationsInput): JsonRecord {
	if (input.query) return { query: input.query }
	if (input.state) {
		return {
			query: {
				field: 'state',
				operator: '=',
				value: input.state,
			},
		}
	}
	throw new Error('searchConversations requires query or state.')
}

async function resolveAdminId(input: IntercomAuthInput & { adminId?: string }): Promise<string> {
	if (input.adminId) return requireString(input.adminId, 'adminId')
	const me = await getMe(input)
	return me.id
}

/**
 * List Intercom conversations.
 * @example
 * import { listConversations } from 'kody:@kody/intercom/conversations'
 * const { items } = await listConversations({ perPage: 10 })
 */
export async function listConversations(
	input: ListConversationsInput = {},
): Promise<ConversationListResult> {
	const result = await intercomRequest<unknown>({
		...input,
		operation: 'conversations.read',
		path: '/conversations',
		query: compactDefined({
			per_page: clampInt(input.perPage, 1, 150, 20),
			starting_after: input.startingAfter,
		}),
	})
	if ('dryRun' in result) {
		throw new Error('listConversations is read-only.')
	}
	return {
		items: extractListItems(result.data)
			.map(mapConversation)
			.filter((item): item is IntercomConversation => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Get one Intercom conversation by id.
 * @example
 * import { getConversation } from 'kody:@kody/intercom/conversations'
 * const conversation = await getConversation({ id })
 */
export async function getConversation(input: GetConversationInput): Promise<IntercomConversation> {
	const id = requireString(input.id, 'id')
	const result = await intercomRequest<unknown>({
		...input,
		operation: 'conversations.read',
		path: `/conversations/${encodeURIComponent(id)}`,
	})
	if ('dryRun' in result) {
		throw new Error('getConversation is read-only.')
	}
	const mapped = mapConversation(result.data)
	if (!mapped) throw new Error('Intercom did not return a conversation id.')
	return mapped
}

/**
 * Search Intercom conversations. POST `/conversations/search` is a read.
 * @example
 * import { searchConversations } from 'kody:@kody/intercom/conversations'
 * const { items } = await searchConversations({ state: 'open' })
 */
export async function searchConversations(
	input: SearchConversationsInput,
): Promise<ConversationListResult> {
	const result = await intercomRequest<unknown>({
		...input,
		operation: 'conversations.read',
		method: 'POST',
		path: '/conversations/search',
		body: compactDefined({
			...searchBody(input),
			pagination: compactDefined({
				per_page: input.perPage === undefined ? undefined : clampInt(input.perPage, 1, 150, 20),
				starting_after: input.startingAfter,
			}),
		}),
	})
	if ('dryRun' in result) {
		throw new Error('searchConversations is read-only.')
	}
	return {
		items: extractListItems(result.data)
			.map(mapConversation)
			.filter((item): item is IntercomConversation => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Reply to an Intercom conversation. Requires `confirm: true`, or use `dryRun: true`.
 * When `adminId` is omitted, the connected `/me` admin is used.
 * @example
 * import { replyConversation } from 'kody:@kody/intercom/conversations'
 * const preview = await replyConversation({ id, body: 'Thanks — looking into this.', dryRun: true })
 */
export async function replyConversation(
	input: ReplyConversationInput,
): Promise<IntercomConversation | DryRunResult> {
	const id = requireString(input.id, 'id')
	const body = requireString(input.body, 'body')
	const adminId = input.dryRun
		? input.adminId ?? '<connected-admin>'
		: await resolveAdminId(input)
	const result = await intercomRequest<unknown>({
		...input,
		operation: 'conversations.write',
		method: 'POST',
		path: `/conversations/${encodeURIComponent(id)}/reply`,
		body: {
			message_type: input.messageType ?? 'comment',
			type: input.type ?? 'admin',
			admin_id: adminId,
			body,
		},
	})
	if ('dryRun' in result) return result
	const mapped = mapConversation(result.data)
	if (!mapped) throw new Error('Intercom did not return a conversation id after reply.')
	return mapped
}

/**
 * Close an Intercom conversation. Requires `confirm: true`, or use `dryRun: true`.
 * When `adminId` is omitted, the connected `/me` admin is used.
 * @example
 * import { closeConversation } from 'kody:@kody/intercom/conversations'
 * const preview = await closeConversation({ id, dryRun: true })
 */
export async function closeConversation(
	input: CloseConversationInput,
): Promise<IntercomConversation | DryRunResult> {
	const id = requireString(input.id, 'id')
	const adminId = input.dryRun
		? input.adminId ?? '<connected-admin>'
		: await resolveAdminId(input)
	const result = await intercomRequest<unknown>({
		...input,
		operation: 'conversations.write',
		method: 'POST',
		path: `/conversations/${encodeURIComponent(id)}/parts`,
		body: {
			message_type: 'close',
			type: 'admin',
			admin_id: adminId,
		},
	})
	if ('dryRun' in result) return result
	const mapped = mapConversation(result.data)
	if (!mapped) throw new Error('Intercom did not return a conversation id after close.')
	return mapped
}

export function parseListConversations(params: Record<string, unknown>): ListConversationsInput {
	const input = requireRecord(params, 'list-conversations')
	return {
		...parseAuth(input),
		perPage: input.perPage as number | undefined,
		startingAfter: optionalString(input.startingAfter, 'startingAfter'),
	}
}

export function parseGetConversation(params: Record<string, unknown>): GetConversationInput {
	const input = requireRecord(params, 'get-conversation')
	return { ...parseAuth(input), id: requireString(input.id, 'id') }
}

export function parseSearchConversations(
	params: Record<string, unknown>,
): SearchConversationsInput {
	const input = requireRecord(params, 'search-conversations')
	return {
		...parseAuth(input),
		query: input.query as SearchConversationsInput['query'],
		state: optionalString(input.state, 'state'),
		perPage: input.perPage as number | undefined,
		startingAfter: optionalString(input.startingAfter, 'startingAfter'),
	}
}

export function parseReplyConversation(params: Record<string, unknown>): ReplyConversationInput {
	const input = requireRecord(params, 'reply-conversation')
	return {
		...parseAuth(input),
		id: requireString(input.id, 'id'),
		body: requireString(input.body, 'body'),
		adminId: optionalString(input.adminId, 'adminId'),
		messageType: optionalString(input.messageType, 'messageType'),
		type: optionalString(input.type, 'type'),
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

export function parseCloseConversation(params: Record<string, unknown>): CloseConversationInput {
	const input = requireRecord(params, 'close-conversation')
	return {
		...parseAuth(input),
		id: requireString(input.id, 'id'),
		adminId: optionalString(input.adminId, 'adminId'),
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

/**
 * Intercom conversation helpers. Writes require `confirm: true` or `dryRun: true`.
 * @example
 * import { listConversations } from 'kody:@kody/intercom/conversations'
 * const { items } = await listConversations({ perPage: 10 })
 */
export default async function conversationsEntrypoint(params: Record<string, unknown> = {}) {
	return listConversations(parseListConversations(params))
}