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,
}
}