Skip to content

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

Package listing

@kody/meta

src/whatsapp.ts

167 lines · 5.2 KB · TypeScript
import { type MetaAccountParams } from './accounts.ts'
import { graphItems, graphRequest, requireConfirmOrDryRun, type JsonObject } from './core.ts'
import { boundedLimit } from './validation.ts'

export type WhatsAppSendParams = MetaAccountParams & {
	phoneNumberId: string
	to: string
	body?: string
	previewUrl?: boolean
	templateName?: string
	templateLanguage?: string
	templateComponents?: unknown[]
	dryRun?: boolean
	confirm?: boolean
}

/** List Businesses the token can access. Use these ids to find WhatsApp accounts — never hard-code one. */
export async function listBusinesses(params: MetaAccountParams & { limit?: number; after?: string } = {}) {
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/me/businesses',
		query: {
			fields: 'id,name,verification_status',
			limit: boundedLimit(params.limit, 25, 100),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

/** List WhatsApp Business accounts owned by a Business. */
export async function listWhatsAppAccounts(
	params: MetaAccountParams & { businessId: string; limit?: number; after?: string },
) {
	const businessId = params.businessId?.trim()
	if (!businessId) throw new Error('listWhatsAppAccounts requires businessId from listBusinesses.')
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(businessId) + '/owned_whatsapp_business_accounts',
		query: {
			fields: 'id,name,currency,timezone_id',
			limit: boundedLimit(params.limit, 25, 100),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

/** List Cloud API phone numbers on a WhatsApp Business account. */
export async function listPhoneNumbers(
	params: MetaAccountParams & { wabaId: string; limit?: number; after?: string },
) {
	const wabaId = params.wabaId?.trim()
	if (!wabaId) throw new Error('listPhoneNumbers requires wabaId from listWhatsAppAccounts.')
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(wabaId) + '/phone_numbers',
		query: {
			fields: 'id,display_phone_number,verified_name,quality_rating,code_verification_status',
			limit: boundedLimit(params.limit, 25, 100),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

/** Read one WhatsApp Cloud phone number. */
export async function getPhoneNumber(params: MetaAccountParams & { phoneNumberId: string }) {
	const phoneNumberId = params.phoneNumberId?.trim()
	if (!phoneNumberId) throw new Error('getPhoneNumber requires phoneNumberId from listPhoneNumbers.')
	return graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(phoneNumberId),
		query: {
			fields: 'id,display_phone_number,verified_name,quality_rating,code_verification_status',
		},
	})
}

/** List message templates on a WhatsApp Business account. */
export async function listTemplates(
	params: MetaAccountParams & { wabaId: string; limit?: number; after?: string },
) {
	const wabaId = params.wabaId?.trim()
	if (!wabaId) throw new Error('listTemplates requires wabaId.')
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(wabaId) + '/message_templates',
		query: {
			fields: 'id,name,language,status,category',
			limit: boundedLimit(params.limit, 25, 100),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

function sendPayload(params: WhatsAppSendParams): JsonObject {
	const to = params.to.replace(/\s+/g, '')
	if (!to) throw new Error('sendMessage requires to (E.164 digits, no spaces).')
	if (params.templateName?.trim()) {
		return {
			messaging_product: 'whatsapp',
			to,
			type: 'template',
			template: {
				name: params.templateName.trim(),
				language: { code: params.templateLanguage?.trim() || 'en_US' },
				components: params.templateComponents ?? [],
			},
		}
	}
	const body = params.body?.trim()
	if (!body) throw new Error('sendMessage requires body for a text message, or templateName for a template.')
	return {
		messaging_product: 'whatsapp',
		to,
		type: 'text',
		text: { body, preview_url: params.previewUrl === true },
	}
}

/**
 * Preview or send a WhatsApp Cloud message.
 * Live send requires confirm: true. This helper never sends without that confirmation.
 */
export async function sendMessage(params: WhatsAppSendParams) {
	const phoneNumberId = params.phoneNumberId?.trim()
	if (!phoneNumberId) {
		throw new Error('sendMessage requires phoneNumberId from listPhoneNumbers. Do not invent a phone number id.')
	}
	const body = sendPayload(params)
	const path = '/' + encodeURIComponent(phoneNumberId) + '/messages'
	const mode = requireConfirmOrDryRun({
		dryRun: params.dryRun,
		confirm: params.confirm,
		action: 'sendMessage',
	})
	if (mode.dryRun) {
		return { dryRun: true as const, method: 'POST', path, body }
	}
	const result = await graphRequest<JsonObject>({
		...params,
		method: 'POST',
		path,
		body,
	})
	return { sent: true as const, to: body.to, result }
}

/**
 * WhatsApp Cloud API helpers.
 * @example
 * import { listBusinesses, sendMessage } from 'kody:@kody/meta/whatsapp'
 * const businesses = await listBusinesses()
 * const preview = await sendMessage({ phoneNumberId, to, body: 'Hi', dryRun: true })
 */
export default function whatsapp() {
	return {
		listBusinesses,
		listWhatsAppAccounts,
		listPhoneNumbers,
		getPhoneNumber,
		listTemplates,
		sendMessage,
	}
}