Skip to content

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

Package listing

@kody/meta

src/instagram.ts

180 lines · 5.7 KB · TypeScript
import { type MetaAccountParams } from './accounts.ts'
import { graphItems, graphRequest, requireConfirmOrDryRun, type JsonObject } from './core.ts'
import { listPages } from './pages.ts'
import { boundedLimit } from './validation.ts'

export type InstagramAccount = {
	pageId: string
	pageName: string | null
	igUserId: string
}

export type InstagramMediaParams = MetaAccountParams & {
	igUserId: string
	limit?: number
	after?: string
	fields?: string
}

export type InstagramPublishParams = MetaAccountParams & {
	igUserId: string
	imageUrl?: string
	videoUrl?: string
	caption?: string
	dryRun?: boolean
	confirm?: boolean
}

const IG_USER_FIELDS = 'id,username,name,profile_picture_url,followers_count,media_count,website'
const IG_MEDIA_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count'

function igUserIdOf(value: unknown): string | null {
	if (!value || typeof value !== 'object') return null
	const id = (value as JsonObject).id
	return typeof id === 'string' ? id : null
}

/** Discover Instagram professional accounts linked to Pages the token can access. */
export async function listInstagramAccounts(params: MetaAccountParams & { limit?: number } = {}) {
	const pages = await listPages({
		...params,
		limit: params.limit,
		fields: 'id,name,instagram_business_account',
	})
	const items: InstagramAccount[] = []
	for (const page of pages.items) {
		const igUserId = igUserIdOf(page.instagram_business_account)
		if (!igUserId) continue
		items.push({
			pageId: typeof page.id === 'string' ? page.id : '',
			pageName: typeof page.name === 'string' ? page.name : null,
			igUserId,
		})
	}
	return { items, next: pages.next }
}

/** Read an Instagram professional account by caller-supplied id. */
export async function getInstagramUser(params: MetaAccountParams & { igUserId: string; fields?: string }) {
	const igUserId = params.igUserId?.trim()
	if (!igUserId) {
		throw new Error('getInstagramUser requires igUserId from listInstagramAccounts (or the account the caller named).')
	}
	return graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(igUserId),
		query: { fields: params.fields ?? IG_USER_FIELDS },
	})
}

/** List recent Instagram media. */
export async function listMedia(params: InstagramMediaParams) {
	const igUserId = params.igUserId?.trim()
	if (!igUserId) throw new Error('listMedia requires igUserId.')
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(igUserId) + '/media',
		query: {
			fields: params.fields ?? IG_MEDIA_FIELDS,
			limit: boundedLimit(params.limit, 10, 50),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

/** Read one Instagram media object. */
export async function getMedia(params: MetaAccountParams & { mediaId: string; fields?: string }) {
	const mediaId = params.mediaId?.trim()
	if (!mediaId) throw new Error('getMedia requires mediaId.')
	return graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(mediaId),
		query: { fields: params.fields ?? IG_MEDIA_FIELDS },
	})
}

/** List comments on an Instagram media object. */
export async function listComments(
	params: MetaAccountParams & { mediaId: string; limit?: number; after?: string },
) {
	const mediaId = params.mediaId?.trim()
	if (!mediaId) throw new Error('listComments requires mediaId.')
	const data = await graphRequest<JsonObject>({
		...params,
		path: '/' + encodeURIComponent(mediaId) + '/comments',
		query: {
			fields: 'id,text,timestamp,username,like_count',
			limit: boundedLimit(params.limit, 10, 50),
			after: params.after,
		},
	})
	return graphItems<JsonObject>(data)
}

/**
 * Preview or publish Instagram media (container + publish).
 * Live publish requires confirm: true. Never posts without that confirmation.
 */
export async function publishMedia(params: InstagramPublishParams) {
	const igUserId = params.igUserId?.trim()
	if (!igUserId) throw new Error('publishMedia requires igUserId from listInstagramAccounts.')
	const imageUrl = params.imageUrl?.trim()
	const videoUrl = params.videoUrl?.trim()
	if (!imageUrl && !videoUrl) throw new Error('publishMedia requires imageUrl or videoUrl.')
	const containerBody: JsonObject = {}
	if (imageUrl) containerBody.image_url = imageUrl
	if (videoUrl) {
		containerBody.media_type = 'REELS'
		containerBody.video_url = videoUrl
	}
	if (params.caption) containerBody.caption = params.caption
	const containerPath = '/' + encodeURIComponent(igUserId) + '/media'
	const publishPath = '/' + encodeURIComponent(igUserId) + '/media_publish'
	const mode = requireConfirmOrDryRun({
		dryRun: params.dryRun,
		confirm: params.confirm,
		action: 'publishMedia',
	})
	if (mode.dryRun) {
		return {
			dryRun: true as const,
			steps: [
				{ method: 'POST', path: containerPath, body: containerBody },
				{ method: 'POST', path: publishPath, body: { creation_id: '<container-id>' } },
			],
		}
	}
	const container = await graphRequest<JsonObject>({
		...params,
		method: 'POST',
		path: containerPath,
		body: containerBody,
	})
	const creationId = typeof container.id === 'string' ? container.id : null
	if (!creationId) throw new Error('Instagram media container did not return an id.')
	const published = await graphRequest<JsonObject>({
		...params,
		method: 'POST',
		path: publishPath,
		body: { creation_id: creationId },
	})
	return { published: true as const, containerId: creationId, mediaId: published.id ?? null }
}

/**
 * Instagram Graph helpers (professional account linked to a Page).
 * @example
 * import { listInstagramAccounts } from 'kody:@kody/meta/instagram'
 * const { items } = await listInstagramAccounts()
 */
export default function instagram() {
	return {
		listInstagramAccounts,
		getInstagramUser,
		listMedia,
		getMedia,
		listComments,
		publishMedia,
	}
}