From 5c37c608e5022c3cbb117b70dc1d307967778dc8 Mon Sep 17 00:00:00 2001 From: gitpascal <44255902+gitpascal@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:23:33 +0100 Subject: [PATCH 1/2] feat(instagram): support Meta Unified Login --- .../channels/instagram/select/page.tsx | 56 +++++- .../instagram/instagram-settings.tsx | 8 +- .../update-instagram-settings.action.ts | 1 + .../instagram/__tests__/connection.test.ts | 17 +- .../__tests__/contact-profile.test.ts | 2 + .../get-contact-profile-pic-url.test.ts | 1 + .../__tests__/get-message-media-urls.test.ts | 1 + .../__tests__/get-user-profile-avatar.test.ts | 1 + .../instagram/__tests__/sync-api.test.ts | 5 + .../instagram/__tests__/unified-login.test.ts | 160 ++++++++++++++++++ integrations/instagram/src/apis/attachment.ts | 4 +- integrations/instagram/src/apis/auth.ts | 101 +++++++++++ integrations/instagram/src/apis/comment.ts | 17 +- .../instagram/src/apis/contact-profile.ts | 15 +- integrations/instagram/src/apis/message.ts | 9 +- integrations/instagram/src/apis/page.ts | 59 ++++--- integrations/instagram/src/apis/post.ts | 15 +- integrations/instagram/src/apis/sync.ts | 29 +++- integrations/instagram/src/apis/user.ts | 4 +- .../instagram/src/handlers/contact.ts | 1 + .../src/handlers/message/media-urls.ts | 1 + integrations/instagram/src/integration.ts | 105 ++++++++++-- integrations/instagram/src/lib/http-client.ts | 30 ++++ integrations/instagram/src/schema.ts | 4 + packages/database/src/partials/credential.ts | 33 +++- 25 files changed, 593 insertions(+), 86 deletions(-) create mode 100644 integrations/instagram/__tests__/unified-login.test.ts diff --git a/apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx b/apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx index 546195d5ef..06d9556f66 100644 --- a/apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx +++ b/apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx @@ -1,6 +1,18 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@chatbotx.io/ui/components/ui/card" import { redirect } from "next/navigation" -import { resolveSelectSession } from "@/features/channel-connect/lib/select-page" +import { getTranslations } from "next-intl/server" +import { CONNECT_PICKER_CARD_CLASS } from "@/features/channel-connect/components/connect-picker-card" +import { + resolveSelectSession, + toConnectPickerItem, +} from "@/features/channel-connect/lib/select-page" import { SelectAccount } from "@/features/integration-instagram/components/select-accounts" +import { SelectFacebookAccounts } from "@/features/integration-instagram/components/select-facebook-accounts" import { createChannelPath } from "@/lib/workspace/create-first-workspace" export const dynamic = "force-dynamic" @@ -15,11 +27,43 @@ export default async function InstagramSelectPage({ }: { searchParams: Promise<{ session?: string; connect_error?: string }> }) { - const { sessionId, resolved } = await resolveSelectSession({ - searchParams, - expectedProvider: "instagram", - noCandidatesError: "noInstagramAccounts", - }) + const [{ sessionId, resolved }, t] = await Promise.all([ + resolveSelectSession({ + searchParams, + expectedProvider: "instagram", + noCandidatesError: "noInstagramAccounts", + }), + getTranslations(), + ]) + + if (resolved.session.targets.length > 1) { + const items = resolved.session.targets + .map((target) => + toConnectPickerItem({ + target, + channel: "instagram", + alreadyConnectedLabel: t("instagram.selectPage.alreadyConnectedNote"), + }), + ) + .sort((current, next) => Number(current.disabled) - Number(next.disabled)) + + return ( + + + + {t("actions.connectFeature", { feature: "Instagram" })} + + + + + + + ) + } const target = resolved.session.targets[0] if (!target) { diff --git a/apps/builder/src/features/platform-credentials/instagram/instagram-settings.tsx b/apps/builder/src/features/platform-credentials/instagram/instagram-settings.tsx index 20bb984e0a..9fd6b3f114 100644 --- a/apps/builder/src/features/platform-credentials/instagram/instagram-settings.tsx +++ b/apps/builder/src/features/platform-credentials/instagram/instagram-settings.tsx @@ -202,9 +202,10 @@ export function EditInstagramSettingsForm({ mode: "onChange", defaultValues: { clientId: publicConfig?.clientId ?? "", - version: publicConfig?.version ?? "v25.0", + version: publicConfig?.version ?? "v27.0", verifyToken: publicConfig?.verifyToken ?? "", clientSecret: "", + unifiedLoginConfigId: publicConfig?.unifiedLoginConfigId ?? "", } satisfies InstagramCredentialUpdate, }, }, @@ -246,6 +247,11 @@ export function EditInstagramSettingsForm({ required /> + +
{publicConfig !== null && ( ({ exchangeCodeForToken: vi.fn(), + exchangeUnifiedCodeForToken: vi.fn(), fetchInstagramAccount: vi.fn(), + generateUnifiedAuthUrl: vi.fn(), + getUnifiedInstagramAccounts: vi.fn(), })) vi.mock("../src/apis/auth", () => ({ exchangeCodeForToken: mocks.exchangeCodeForToken, + exchangeUnifiedCodeForToken: mocks.exchangeUnifiedCodeForToken, fetchInstagramAccount: mocks.fetchInstagramAccount, + generateUnifiedAuthUrl: mocks.generateUnifiedAuthUrl, + getUnifiedInstagramAccounts: mocks.getUnifiedInstagramAccounts, })) const { integration } = await import("../src/integration") @@ -47,14 +53,21 @@ describe("Instagram connection identity", () => { throw new Error("Instagram connection has no exchangeCode") } - expect(auth).toMatchObject({ + const [candidate] = + (await integration.connection.listCandidates?.({ auth })) ?? [] + expect(candidate?.auth).toMatchObject({ metadata: { igId: "instagram-user-id", pageId: "instagram-page-id", }, }) - await expect(integration.connection.verify({ auth })).resolves.toEqual({ + if (!candidate) { + throw new Error("Instagram connection has no candidate") + } + await expect( + integration.connection.verify({ auth: candidate.auth }), + ).resolves.toEqual({ ok: true, }) }) diff --git a/integrations/instagram/__tests__/contact-profile.test.ts b/integrations/instagram/__tests__/contact-profile.test.ts index c8a667bd16..d180ef9973 100644 --- a/integrations/instagram/__tests__/contact-profile.test.ts +++ b/integrations/instagram/__tests__/contact-profile.test.ts @@ -6,6 +6,8 @@ vi.mock("../src/lib/http-client", () => ({ instagramBusinessClient: { get: instagramGet, }, + getInstagramGraphClient: () => ({ get: instagramGet }), + getInstagramGraphClientForLoginType: () => ({ get: instagramGet }), })) const { fetchInstagramContactProfile } = await import( diff --git a/integrations/instagram/__tests__/get-contact-profile-pic-url.test.ts b/integrations/instagram/__tests__/get-contact-profile-pic-url.test.ts index a82fcc76bf..58fb8a031c 100644 --- a/integrations/instagram/__tests__/get-contact-profile-pic-url.test.ts +++ b/integrations/instagram/__tests__/get-contact-profile-pic-url.test.ts @@ -13,6 +13,7 @@ vi.mock("../src/lib/http-client", async (importOriginal) => { return { ...actual, instagramBusinessClient: { get: mockGet }, + getInstagramGraphClient: () => ({ get: mockGet }), } }) diff --git a/integrations/instagram/__tests__/get-message-media-urls.test.ts b/integrations/instagram/__tests__/get-message-media-urls.test.ts index 6cee9afcac..43575dd40b 100644 --- a/integrations/instagram/__tests__/get-message-media-urls.test.ts +++ b/integrations/instagram/__tests__/get-message-media-urls.test.ts @@ -12,6 +12,7 @@ vi.mock("../src/lib/http-client", async (importOriginal) => { return { ...actual, instagramBusinessClient: { get: mockGet }, + getInstagramGraphClientForLoginType: () => ({ get: mockGet }), } }) diff --git a/integrations/instagram/__tests__/get-user-profile-avatar.test.ts b/integrations/instagram/__tests__/get-user-profile-avatar.test.ts index 1168dbe7d9..d23ecd9fb8 100644 --- a/integrations/instagram/__tests__/get-user-profile-avatar.test.ts +++ b/integrations/instagram/__tests__/get-user-profile-avatar.test.ts @@ -7,6 +7,7 @@ const mockStoreIncomingImage = vi.hoisted(() => vi.fn()) vi.mock("../src/lib/http-client", () => ({ instagramBusinessClient: { get: instagramGet }, + getInstagramGraphClient: () => ({ get: instagramGet }), })) vi.mock("@chatbotx.io/utils/media-download", () => ({ diff --git a/integrations/instagram/__tests__/sync-api.test.ts b/integrations/instagram/__tests__/sync-api.test.ts index 63b794f72b..7d6f16b719 100644 --- a/integrations/instagram/__tests__/sync-api.test.ts +++ b/integrations/instagram/__tests__/sync-api.test.ts @@ -10,6 +10,11 @@ vi.mock("../src/lib/http-client", () => ({ get: mockGet, getWithHeaders: mockGetWithHeaders, }, + instagramUnifiedCoexistGraphClient: { + get: mockGet, + getWithHeaders: mockGetWithHeaders, + }, + getInstagramGraphClientForLoginType: () => ({ get: mockGet }), })) const { fetchInstagramConversationMessages, listInstagramConversations } = diff --git a/integrations/instagram/__tests__/unified-login.test.ts b/integrations/instagram/__tests__/unified-login.test.ts new file mode 100644 index 0000000000..078b8fcd03 --- /dev/null +++ b/integrations/instagram/__tests__/unified-login.test.ts @@ -0,0 +1,160 @@ +import { HttpResponse, http, server } from "@chatbotx.io/vitest-config/msw" +import { describe, expect, test } from "vitest" +import { + exchangeUnifiedCodeForToken, + generateUnifiedAuthUrl, + getUnifiedInstagramAccounts, +} from "../src/apis/auth" +import { sendMessage } from "../src/apis/message" +import { subscribePageToInstagramWebhook } from "../src/apis/page" +import type { InstagramAuthValue } from "../src/schema" + +const GRAPH_URL = "https://graph.facebook.com" +const VERSION = "v27.0" +const ACCESS_TOKEN = "business-integration-system-user-token" + +const unifiedAuth = { + authType: "oauth2", + clientId: "meta-app-id", + clientSecret: "meta-app-secret", + redirectUrl: "https://chatbotx.example/integrations/instagram/callback", + tokens: { accessToken: ACCESS_TOKEN }, + metadata: { + igId: "instagram-professional-account-id", + igName: "Example Account", + pageId: "instagram-professional-account-id", + version: VERSION, + loginType: "unified", + }, +} satisfies InstagramAuthValue + +describe("Instagram Unified Login", () => { + test("builds Meta's Facebook Login for Business dialog URL", () => { + const url = new URL( + generateUnifiedAuthUrl({ + clientId: "1466909638750744", + configId: "2231093364150040", + redirectUrl: + "https://chatbotx.herochamber.com/integrations/instagram/callback", + state: "opaque-state", + }), + ) + + expect(`${url.origin}${url.pathname}`).toBe( + "https://www.facebook.com/dialog/oauth", + ) + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: "1466909638750744", + config_id: "2231093364150040", + response_type: "code", + override_default_response_type: "true", + state: "opaque-state", + }) + expect(url.searchParams.has("scope")).toBe(false) + }) + + test("exchanges the code for a BISU token and resolves the client business", async () => { + server.use( + http.get(`${GRAPH_URL}/${VERSION}/oauth/access_token`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get("client_id")).toBe("meta-app-id") + expect(params.get("client_secret")).toBe("meta-app-secret") + expect(params.get("code")).toBe("authorization-code") + return HttpResponse.json({ access_token: ACCESS_TOKEN }) + }), + http.get(`${GRAPH_URL}/${VERSION}/me`, ({ request }) => { + expect(new URL(request.url).searchParams.get("fields")).toBe( + "client_business_id", + ) + return HttpResponse.json({ client_business_id: "business-id" }) + }), + ) + + await expect( + exchangeUnifiedCodeForToken( + { + clientId: "meta-app-id", + clientSecret: "meta-app-secret", + version: VERSION, + }, + "authorization-code", + ), + ).resolves.toEqual({ + accessToken: ACCESS_TOKEN, + clientBusinessId: "business-id", + }) + }) + + test("maps every assigned Instagram account to a selectable candidate", async () => { + server.use( + http.get( + `${GRAPH_URL}/${VERSION}/me/assigned_instagram_accounts`, + ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get("fields")).toBe("id,username") + if (params.get("after") === "page-2") { + return HttpResponse.json({ + data: [{ id: "ig-2", username: "second" }], + }) + } + return HttpResponse.json({ + data: [{ id: "ig-1", username: "first.account" }], + paging: { + cursors: { after: "page-2" }, + next: "https://graph.facebook.com/next", + }, + }) + }, + ), + ) + + await expect( + getUnifiedInstagramAccounts(ACCESS_TOKEN, VERSION), + ).resolves.toEqual([ + expect.objectContaining({ id: "ig-1", userId: "ig-1" }), + expect.objectContaining({ id: "ig-2", userId: "ig-2" }), + ]) + }) + + test("uses graph.facebook.com and the explicit account id for messages", async () => { + server.use( + http.post( + `${GRAPH_URL}/${VERSION}/instagram-professional-account-id/messages`, + () => HttpResponse.json({ message_id: "message-id" }), + ), + ) + + await expect( + sendMessage(unifiedAuth, { + recipient: { id: "igsid" }, + message: { text: "Hello" }, + }), + ).resolves.toEqual({ message_id: "message-id" }) + }) + + test("subscribes the selected account with Unified-compatible fields", async () => { + server.use( + http.post( + `${GRAPH_URL}/${VERSION}/instagram-professional-account-id/subscribed_apps`, + ({ request }) => { + const fields = new URL(request.url).searchParams.get( + "subscribed_fields", + ) + expect(fields).toContain("messages") + expect(fields).toContain("message_reactions") + expect(fields).not.toContain("messaging_optins") + return HttpResponse.json({ success: true }) + }, + ), + ) + + await expect( + subscribePageToInstagramWebhook({ + igId: unifiedAuth.metadata.igId, + accessToken: ACCESS_TOKEN, + version: VERSION, + loginType: "unified", + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/integrations/instagram/src/apis/attachment.ts b/integrations/instagram/src/apis/attachment.ts index 6a173e6f05..64b723b324 100644 --- a/integrations/instagram/src/apis/attachment.ts +++ b/integrations/instagram/src/apis/attachment.ts @@ -12,7 +12,7 @@ import { } from "@chatbotx.io/utils/store-incoming-image" import fetch from "cross-fetch" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { getInstagramGraphClient } from "../lib/http-client" import type { InstagramAttachment, InstagramAuthValue, @@ -28,7 +28,7 @@ export const uploadAttachment = ( const endpoint = `${auth.metadata.version}/${auth.metadata.pageId}/message_attachments` return rescue(endpoint, () => - instagramBusinessClient.post(endpoint, { + getInstagramGraphClient(auth).post(endpoint, { headers: { Authorization: `Bearer ${auth.tokens.accessToken}`, }, diff --git a/integrations/instagram/src/apis/auth.ts b/integrations/instagram/src/apis/auth.ts index 227f748f88..063cb2f217 100644 --- a/integrations/instagram/src/apis/auth.ts +++ b/integrations/instagram/src/apis/auth.ts @@ -3,6 +3,7 @@ import { InstagramException, rescue } from "../exception" import { instagramBusinessClient, instagramOAuthClient, + instagramUnifiedGraphClient, } from "../lib/http-client" import { logger } from "../lib/logger" @@ -23,6 +24,11 @@ export type InstagramAccount = { accessToken: string } +export type UnifiedInstagramAuthorization = { + accessToken: string + clientBusinessId?: string +} + export function generateAuthUrl({ clientId, redirectUrl, @@ -43,6 +49,101 @@ export function generateAuthUrl({ return `${INSTAGRAM_OAUTH_AUTHORIZE_URL}?${params.toString()}` } +export function generateUnifiedAuthUrl({ + clientId, + configId, + redirectUrl, + state, +}: { + clientId: string + configId: string + redirectUrl: string + state: string +}): string { + const params = new URLSearchParams({ + client_id: clientId, + config_id: configId, + redirect_uri: redirectUrl, + response_type: "code", + override_default_response_type: "true", + state, + }) + return `https://www.facebook.com/dialog/oauth?${params.toString()}` +} + +export function exchangeUnifiedCodeForToken( + settings: { clientId: string; clientSecret: string; version?: string }, + code: string, +): Promise { + const version = settings.version ?? "v22.0" + const endpoint = `${version}/oauth/access_token` + return rescue(endpoint, async () => { + const token = await instagramUnifiedGraphClient.get<{ + access_token: string + }>(endpoint, { + searchParams: { + client_id: settings.clientId, + client_secret: settings.clientSecret, + code, + }, + }) + const business = await instagramUnifiedGraphClient.get<{ + client_business_id?: string + }>(`${version}/me`, { + searchParams: { + fields: "client_business_id", + access_token: token.access_token, + }, + }) + return { + accessToken: token.access_token, + clientBusinessId: business.client_business_id, + } + }) +} + +export async function getUnifiedInstagramAccounts( + accessToken: string, + version = "v22.0", +): Promise { + const endpoint = `${version}/me/assigned_instagram_accounts` + const accounts: InstagramAccount[] = [] + let after: string | undefined + + do { + const response = await rescue(endpoint, () => + instagramUnifiedGraphClient.get<{ + data: Array<{ + id: string + username?: string + }> + paging?: { cursors?: { after?: string }; next?: string } + }>(endpoint, { + searchParams: { + fields: "id,username", + access_token: accessToken, + ...(after ? { after } : {}), + }, + }), + ) + accounts.push( + ...response.data.map((account) => ({ + id: account.id, + userId: account.id, + name: account.username ?? account.id, + username: account.username ?? account.id, + accessToken, + })), + ) + const nextAfter = response.paging?.next + ? response.paging.cursors?.after + : undefined + after = nextAfter && nextAfter !== after ? nextAfter : undefined + } while (after) + + return accounts +} + // Step 1: exchange authorization code → short-lived user access token + user ID export function exchangeCodeForToken( settings: { clientId: string; clientSecret: string; version?: string }, diff --git a/integrations/instagram/src/apis/comment.ts b/integrations/instagram/src/apis/comment.ts index 73d28fee13..3e46b24024 100644 --- a/integrations/instagram/src/apis/comment.ts +++ b/integrations/instagram/src/apis/comment.ts @@ -1,6 +1,9 @@ import { DEFAULT_API_VERSION } from "../constants" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { + getInstagramAccountNode, + getInstagramGraphClient, +} from "../lib/http-client" import { INSTAGRAM_MESSAGE_METADATA, type InstagramAuthValue, @@ -18,7 +21,7 @@ export const replyToComment = ( const endpoint = `${version}/${commentId}/replies` return rescue(endpoint, () => - instagramBusinessClient.post<{ id: string }>(endpoint, { + getInstagramGraphClient(auth).post<{ id: string }>(endpoint, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.tokens.accessToken}`, @@ -37,7 +40,7 @@ export const deleteComment = ( const endpoint = `${version}/${commentId}` return rescue(endpoint, () => - instagramBusinessClient.delete<{ success: boolean }>(endpoint, { + getInstagramGraphClient(auth).delete<{ success: boolean }>(endpoint, { headers: { Authorization: `Bearer ${auth.tokens.accessToken}`, }, @@ -54,7 +57,7 @@ export const hideComment = ( const endpoint = `${version}/${commentId}` return rescue(endpoint, () => - instagramBusinessClient.post<{ success: boolean }>(endpoint, { + getInstagramGraphClient(auth).post<{ success: boolean }>(endpoint, { headers: { Authorization: `Bearer ${auth.tokens.accessToken}`, }, @@ -82,10 +85,10 @@ export const sendPrivateReplyMessage = ( message: InstagramSendMessage | InstagramMessageAttachmentPayload, ): Promise => { const version = auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/messages` + const endpoint = `${version}/${getInstagramAccountNode(auth)}/messages` return rescue(endpoint, () => - instagramBusinessClient.post(endpoint, { + getInstagramGraphClient(auth).post(endpoint, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.tokens.accessToken}`, @@ -142,7 +145,7 @@ export const listMediaComments = (props: { const endpoint = `${version}/${mediaId}/comments` return rescue(endpoint, async () => { - const res = await instagramBusinessClient.get<{ + const res = await getInstagramGraphClient(auth).get<{ data: InstagramMediaComment[] paging?: { cursors?: { after?: string }; next?: string } }>(endpoint, { diff --git a/integrations/instagram/src/apis/contact-profile.ts b/integrations/instagram/src/apis/contact-profile.ts index 4d6a4dc185..56220725c1 100644 --- a/integrations/instagram/src/apis/contact-profile.ts +++ b/integrations/instagram/src/apis/contact-profile.ts @@ -1,7 +1,7 @@ import { DEFAULT_API_VERSION } from "../constants" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" -import type { InstagramContactProfile } from "../schema" +import { getInstagramGraphClientForLoginType } from "../lib/http-client" +import type { InstagramAuthValue, InstagramContactProfile } from "../schema" type RawContactProfileResponse = { id: string @@ -16,6 +16,7 @@ export const fetchInstagramContactProfile = (props: { igsid: string accessToken: string version?: string + loginType?: InstagramAuthValue["metadata"]["loginType"] }): Promise => { const { igsid, accessToken, version = DEFAULT_API_VERSION } = props const endpoint = `${version}/${igsid}` @@ -29,11 +30,11 @@ export const fetchInstagramContactProfile = (props: { "username,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user", }) - const response = - await instagramBusinessClient.get( - `${endpoint}?${queries.toString()}`, - { headers: { Authorization: `Bearer ${accessToken}` } }, - ) + const response = await getInstagramGraphClientForLoginType( + props.loginType, + ).get(`${endpoint}?${queries.toString()}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) return { username: response.username ?? null, diff --git a/integrations/instagram/src/apis/message.ts b/integrations/instagram/src/apis/message.ts index 34fe15122b..abe8bd0ff4 100644 --- a/integrations/instagram/src/apis/message.ts +++ b/integrations/instagram/src/apis/message.ts @@ -1,6 +1,9 @@ import { DEFAULT_API_VERSION } from "../constants" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { + getInstagramAccountNode, + getInstagramGraphClient, +} from "../lib/http-client" import type { InstagramAuthValue, InstagramSendMessageRequest, @@ -12,10 +15,10 @@ export const sendMessage = ( payload: InstagramSendMessageRequest, ): Promise => { const version = auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/messages` + const endpoint = `${version}/${getInstagramAccountNode(auth)}/messages` return rescue(endpoint, () => - instagramBusinessClient.post(endpoint, { + getInstagramGraphClient(auth).post(endpoint, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.tokens.accessToken}`, diff --git a/integrations/instagram/src/apis/page.ts b/integrations/instagram/src/apis/page.ts index 32211c1a33..07d2165782 100644 --- a/integrations/instagram/src/apis/page.ts +++ b/integrations/instagram/src/apis/page.ts @@ -1,7 +1,12 @@ import type { Context } from "@chatbotx.io/sdk" import { DEFAULT_API_VERSION } from "../constants" import { InstagramAPIException, rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { + getInstagramAccountNode, + getInstagramGraphClient, + getInstagramGraphClientForLoginType, + instagramBusinessClient, +} from "../lib/http-client" import type { InstagramAuthValue, InstagramProfileRequest } from "../schema" export const INSTAGRAM_SUBSCRIBE_FIELDS = [ @@ -42,12 +47,12 @@ export const getAccountPictureUrl = async (props: { const { ctx } = props const version = ctx.auth.metadata.version ?? DEFAULT_API_VERSION const accessToken = ctx.auth.tokens.accessToken - const endpoint = `${version}/me` + const endpoint = `${version}/${getInstagramAccountNode(ctx.auth)}` try { return await rescue(endpoint, async () => { const res: { profile_picture_url?: string } = - await instagramBusinessClient.get(endpoint, { + await getInstagramGraphClient(ctx.auth).get(endpoint, { searchParams: { fields: "profile_picture_url", access_token: accessToken, @@ -64,23 +69,27 @@ export const subscribePageToInstagramWebhook = (props: { igId: string accessToken: string version?: string + loginType?: "direct" | "unified" }): Promise => { const { version = DEFAULT_API_VERSION } = props const endpoint = `${version}/${props.igId}/subscribed_apps` - const subscribedFields = INSTAGRAM_SUBSCRIBE_FIELDS.join(",") + const subscribedFields = [ + ...INSTAGRAM_SUBSCRIBE_FIELDS.filter( + (field) => props.loginType !== "unified" || field !== "messaging_optins", + ), + ...(props.loginType === "unified" ? ["message_reactions"] : []), + ].join(",") return rescue(endpoint, async () => { - const response = await instagramBusinessClient.post<{ success?: boolean }>( - endpoint, - { - headers: { - Authorization: `Bearer ${props.accessToken}`, - }, - json: { - subscribed_fields: subscribedFields, - }, + const client = getInstagramGraphClientForLoginType(props.loginType) + const response = await client.post<{ success?: boolean }>(endpoint, { + headers: { + Authorization: `Bearer ${props.accessToken}`, }, - ) + ...(props.loginType === "unified" + ? { searchParams: { subscribed_fields: subscribedFields } } + : { json: { subscribed_fields: subscribedFields } }), + }) if (response.success !== true) { throw new InstagramAPIException( `Subscribe failed for Instagram page ${props.igId}`, @@ -93,12 +102,14 @@ export const unsubscribePageFromInstagramWebhook = (props: { igId: string accessToken: string version?: string + loginType?: "direct" | "unified" }): Promise => { const { version = DEFAULT_API_VERSION } = props const endpoint = `${version}/${props.igId}/subscribed_apps` return rescue(endpoint, async () => { - const response = await instagramBusinessClient.delete<{ + const client = getInstagramGraphClientForLoginType(props.loginType) + const response = await client.delete<{ success?: boolean }>(endpoint, { headers: { @@ -119,10 +130,10 @@ export const deleteProfileFields = (props: { }): Promise => { const { ctx, fields } = props const version = ctx.auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/messenger_profile` + const endpoint = `${version}/${getInstagramAccountNode(ctx.auth)}/messenger_profile` return rescue(endpoint, () => - instagramBusinessClient.delete(endpoint, { + getInstagramGraphClient(ctx.auth).delete(endpoint, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.auth.tokens.accessToken}`, @@ -141,7 +152,7 @@ export const updateProfile = (props: { }): Promise => { const { ctx, params } = props const version = ctx.auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/messenger_profile` + const endpoint = `${version}/${getInstagramAccountNode(ctx.auth)}/messenger_profile` return rescue(endpoint, () => { const queries = new URLSearchParams({ @@ -149,7 +160,7 @@ export const updateProfile = (props: { access_token: ctx.auth.tokens.accessToken, }).toString() - return instagramBusinessClient.post(`${endpoint}?${queries}`, { + return getInstagramGraphClient(ctx.auth).post(`${endpoint}?${queries}`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.auth.tokens.accessToken}`, @@ -169,7 +180,7 @@ export const getPersistentMenu = (props: { }> => { const { ctx } = props const version = ctx.auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/messenger_profile` + const endpoint = `${version}/${getInstagramAccountNode(ctx.auth)}/messenger_profile` return rescue(endpoint, async () => { const queries = new URLSearchParams({ @@ -180,7 +191,7 @@ export const getPersistentMenu = (props: { const response: { persistent_menu?: InstagramProfileRequest["persistent_menu"] - } = await instagramBusinessClient.get(`${endpoint}?${queries}`, { + } = await getInstagramGraphClient(ctx.auth).get(`${endpoint}?${queries}`, { headers: { Authorization: `Bearer ${ctx.auth.tokens.accessToken}`, }, @@ -211,11 +222,11 @@ export const addBranding = async (props: { url: props.url, } - const endpoint = `${version}/me/messenger_profile` + const endpoint = `${version}/${getInstagramAccountNode(ctx.auth)}/messenger_profile` if (!persistentMenu || persistentMenu.length === 0) { await rescue(endpoint, () => - instagramBusinessClient.post(`${endpoint}?${queries}`, { + getInstagramGraphClient(ctx.auth).post(`${endpoint}?${queries}`, { headers: { "Content-Type": "application/json" }, json: { platform: "instagram", @@ -255,7 +266,7 @@ export const addBranding = async (props: { }) await rescue(endpoint, () => - instagramBusinessClient.post(`${endpoint}?${queries}`, { + getInstagramGraphClient(ctx.auth).post(`${endpoint}?${queries}`, { headers: { "Content-Type": "application/json" }, json: { platform: "instagram", diff --git a/integrations/instagram/src/apis/post.ts b/integrations/instagram/src/apis/post.ts index b5c8e5683d..5e449c9d0a 100644 --- a/integrations/instagram/src/apis/post.ts +++ b/integrations/instagram/src/apis/post.ts @@ -1,7 +1,10 @@ import type { ChannelPostDetails, Context } from "@chatbotx.io/sdk" import { DEFAULT_API_VERSION } from "../constants" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { + getInstagramAccountNode, + getInstagramGraphClient, +} from "../lib/http-client" import type { InstagramAuthValue } from "../schema" export type InstagramMediaDetails = { @@ -50,10 +53,10 @@ export const listInstagramMedia = (props: { }): Promise => { const { auth } = props const version = auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/media` + const endpoint = `${version}/${getInstagramAccountNode(auth)}/media` return rescue(endpoint, async () => { - const res = await instagramBusinessClient.get< + const res = await getInstagramGraphClient(auth).get< InstagramPaginatedResponse >(endpoint, { headers: { @@ -79,10 +82,10 @@ export const listInstagramStories = (props: { }): Promise => { const { auth } = props const version = auth.metadata.version ?? DEFAULT_API_VERSION - const endpoint = `${version}/me/stories` + const endpoint = `${version}/${getInstagramAccountNode(auth)}/stories` return rescue(endpoint, async () => { - const res = await instagramBusinessClient.get< + const res = await getInstagramGraphClient(auth).get< InstagramPaginatedResponse >(endpoint, { headers: { @@ -107,7 +110,7 @@ export const getPostDetails = (props: { const endpoint = `${version}/${input.postId}` return rescue(endpoint, () => - instagramBusinessClient.get(endpoint, { + getInstagramGraphClient(ctx.auth).get(endpoint, { headers: { Authorization: `Bearer ${ctx.auth.tokens.accessToken}`, }, diff --git a/integrations/instagram/src/apis/sync.ts b/integrations/instagram/src/apis/sync.ts index 67cfdff3cf..75b9cd0324 100644 --- a/integrations/instagram/src/apis/sync.ts +++ b/integrations/instagram/src/apis/sync.ts @@ -1,8 +1,9 @@ import { DEFAULT_API_VERSION } from "../constants" import { rescue } from "../exception" import { - instagramBusinessClient, + getInstagramGraphClientForLoginType, instagramCoexistGraphClient, + instagramUnifiedCoexistGraphClient, } from "../lib/http-client" import { createGraphConversationSync, @@ -33,10 +34,17 @@ const sync = createGraphConversationSync({ rescue, }) +const unifiedSync = createGraphConversationSync({ + client: instagramUnifiedCoexistGraphClient, + defaultVersion: DEFAULT_API_VERSION, + rescue, +}) + export const getMessageMediaUrls = (props: { graphMessageId: string accessToken: string version?: string + loginType?: "direct" | "unified" }): Promise< Array<{ sourceId: string; url: string; mimeType: string | null }> > => { @@ -44,7 +52,9 @@ export const getMessageMediaUrls = (props: { const endpoint = `${version}/${graphMessageId}` return rescue(endpoint, async () => { - const response = await instagramBusinessClient.get<{ + const response = await getInstagramGraphClientForLoginType( + props.loginType, + ).get<{ attachments?: { data?: InstagramHistoryAttachment[] } }>(endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, @@ -77,8 +87,9 @@ export const listInstagramConversations = (props: { accessToken: string version?: string after?: string + loginType?: "direct" | "unified" }): Promise> => - sync.listConversations({ + (props.loginType === "unified" ? unifiedSync : sync).listConversations({ node: props.igUserId, accessToken: props.accessToken, version: props.version, @@ -90,11 +101,19 @@ export const fetchInstagramConversationMessages = (props: { accessToken: string version?: string after?: string + loginType?: "direct" | "unified" }): Promise> => - sync.fetchConversationMessages(props) + (props.loginType === "unified" + ? unifiedSync + : sync + ).fetchConversationMessages(props) export const fetchInstagramParticipantProfile = (props: { userId: string accessToken: string version?: string -}) => sync.fetchContactProfile(props) + loginType?: "direct" | "unified" +}) => + (props.loginType === "unified" ? unifiedSync : sync).fetchContactProfile( + props, + ) diff --git a/integrations/instagram/src/apis/user.ts b/integrations/instagram/src/apis/user.ts index 7d3866fb0f..71ccfa52b4 100644 --- a/integrations/instagram/src/apis/user.ts +++ b/integrations/instagram/src/apis/user.ts @@ -3,7 +3,7 @@ import { createId } from "@chatbotx.io/utils" import { fetchMediaWithLimits } from "@chatbotx.io/utils/media-download" import { storeIncomingImage } from "@chatbotx.io/utils/store-incoming-image" import { rescue } from "../exception" -import { instagramBusinessClient } from "../lib/http-client" +import { getInstagramGraphClient } from "../lib/http-client" import { logger } from "../lib/logger" import type { InstagramAuthValue, InstagramUserProfile } from "../schema" @@ -19,7 +19,7 @@ const fetchUserProfile = async ({ const queries = new URLSearchParams({ fields: "id,name,username,profile_pic", }) - return await instagramBusinessClient.get( + return await getInstagramGraphClient(ctx.auth).get( `${ctx.auth.metadata.version}/${psid}?${queries.toString()}`, { headers: { Authorization: `Bearer ${ctx.auth.tokens.accessToken}` } }, ) diff --git a/integrations/instagram/src/handlers/contact.ts b/integrations/instagram/src/handlers/contact.ts index 005a22c6b0..49734573dd 100644 --- a/integrations/instagram/src/handlers/contact.ts +++ b/integrations/instagram/src/handlers/contact.ts @@ -13,6 +13,7 @@ const getProfileSnapshot: NonNullable< igsid: sourceId, accessToken: ctx.auth.tokens.accessToken, version: ctx.auth.metadata.version, + loginType: ctx.auth.metadata.loginType, }) return { followsBusiness: profile.followsBusiness, diff --git a/integrations/instagram/src/handlers/message/media-urls.ts b/integrations/instagram/src/handlers/message/media-urls.ts index fe010c45d2..21b2407242 100644 --- a/integrations/instagram/src/handlers/message/media-urls.ts +++ b/integrations/instagram/src/handlers/message/media-urls.ts @@ -8,4 +8,5 @@ export const getMessageMediaUrls: MessageHandlers["getMessag graphMessageId: data.graphMessageId, accessToken: ctx.auth.tokens.accessToken, version: ctx.auth.metadata.version, + loginType: ctx.auth.metadata.loginType, }) diff --git a/integrations/instagram/src/integration.ts b/integrations/instagram/src/integration.ts index b19a6b82a1..a00010247a 100644 --- a/integrations/instagram/src/integration.ts +++ b/integrations/instagram/src/integration.ts @@ -5,7 +5,13 @@ import { oauth2Auth, probeVerify, } from "@chatbotx.io/sdk" -import { exchangeCodeForToken, fetchInstagramAccount } from "./apis/auth" +import { + exchangeCodeForToken, + exchangeUnifiedCodeForToken, + fetchInstagramAccount, + generateUnifiedAuthUrl, + getUnifiedInstagramAccounts, +} from "./apis/auth" import { refreshLongLivedToken, subscribePageToInstagramWebhook, @@ -36,10 +42,18 @@ const config: IntegrationDefinition< connection: { kind: "channel", strategy: "oauth_redirect", - multiAccount: false, + multiAccount: true, configFields: [], authorizeUrl: ({ credential, callbackUrl, state }) => { const config = credential as InstagramConfig + if (config.unifiedLoginConfigId) { + return generateUnifiedAuthUrl({ + clientId: config.clientId, + configId: config.unifiedLoginConfigId, + redirectUrl: callbackUrl, + state, + }) + } const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: callbackUrl, @@ -51,30 +65,72 @@ const config: IntegrationDefinition< }, exchangeCode: async ({ code, callbackUrl, credential }) => { const config = credential as InstagramConfig + if (config.unifiedLoginConfigId) { + const authorization = await exchangeUnifiedCodeForToken(config, code) + return oauth2Auth( + config, + callbackUrl, + { accessToken: authorization.accessToken }, + { + igId: "", + igName: "", + pageId: "", + version: config.version, + loginType: "unified", + clientBusinessId: authorization.clientBusinessId, + }, + ) satisfies InstagramAuthValue + } const { accessToken } = await exchangeCodeForToken( config, code, callbackUrl, ) - const account = await fetchInstagramAccount(accessToken) - if (!account) { - throw new Error( - "Instagram account is not a supported Business/Creator account.", - ) - } return oauth2Auth( config, callbackUrl, { accessToken }, { - igId: account.userId, - igName: account.name, - pageId: account.id, + igId: "", + igName: "", + pageId: "", version: config.version, - username: account.username, + loginType: "direct", }, ) satisfies InstagramAuthValue }, + listCandidates: async ({ auth }) => { + const accounts = + auth.metadata.loginType === "unified" + ? await getUnifiedInstagramAccounts( + auth.tokens.accessToken, + auth.metadata.version, + ) + : await (async () => { + const account = await fetchInstagramAccount( + auth.tokens.accessToken, + ) + return account ? [account] : [] + })() + + return accounts.map((account) => ({ + sourceId: account.userId, + displayName: account.name, + avatarUrl: account.profile_picture_url, + auth: oauth2Auth( + auth, + auth.redirectUrl, + { accessToken: auth.tokens.accessToken }, + { + ...auth.metadata, + igId: account.userId, + igName: account.name, + pageId: account.id, + username: account.username, + }, + ) satisfies InstagramAuthValue, + })) + }, candidateToConfig: (auth) => ({ pageId: auth.metadata.pageId, username: auth.metadata.username, @@ -86,8 +142,21 @@ const config: IntegrationDefinition< verify: async ({ auth }) => await probeVerify( async () => { - const account = await fetchInstagramAccount(auth.tokens.accessToken) - if (!account || account.userId !== auth.metadata.igId) { + const accounts = + auth.metadata.loginType === "unified" + ? await getUnifiedInstagramAccounts( + auth.tokens.accessToken, + auth.metadata.version, + ) + : await (async () => { + const account = await fetchInstagramAccount( + auth.tokens.accessToken, + ) + return account ? [account] : [] + })() + if ( + !accounts.some((account) => account.userId === auth.metadata.igId) + ) { throw new Error("Instagram account could not be verified") } }, @@ -104,12 +173,14 @@ const config: IntegrationDefinition< igId: auth.metadata.pageId, accessToken: auth.tokens.accessToken, version: auth.metadata.version, + loginType: auth.metadata.loginType, }), unsubscribe: ({ auth }) => unsubscribePageFromInstagramWebhook({ igId: auth.metadata.pageId, accessToken: auth.tokens.accessToken, version: auth.metadata.version, + loginType: auth.metadata.loginType, }), }, }, @@ -143,9 +214,15 @@ const config: IntegrationDefinition< igId: auth.metadata.pageId, accessToken: auth.tokens.accessToken, version: auth.metadata.version, + loginType: auth.metadata.loginType, }) }, refreshAuth: async ({ auth }) => { + // Unified Login returns a Business Integration System User token. It is + // not an Instagram user token and must never be sent to ig_refresh_token. + if (auth.metadata.loginType === "unified") { + return auth + } const refreshed = await refreshLongLivedToken(auth.tokens.accessToken) return { ...auth, diff --git a/integrations/instagram/src/lib/http-client.ts b/integrations/instagram/src/lib/http-client.ts index 0441a4e606..358f9512d8 100644 --- a/integrations/instagram/src/lib/http-client.ts +++ b/integrations/instagram/src/lib/http-client.ts @@ -7,6 +7,7 @@ import { InstagramAPIException, parseOriginError, } from "../exception" +import type { InstagramAuthValue } from "../schema" import { logger } from "./logger" const EXPECTED_POLICY_ERRORS: ReadonlyArray<{ @@ -177,6 +178,29 @@ export const instagramOAuthClient = new InstagramHttpClient({ retryDelay: 1000, }) +export const instagramUnifiedGraphClient = new InstagramHttpClient({ + baseUrl: "https://graph.facebook.com", + timeout: 30_000, + retries: 3, + retryDelay: 1000, +}) + +export const getInstagramGraphClient = ( + auth: Pick, +): InstagramHttpClient => + getInstagramGraphClientForLoginType(auth.metadata.loginType) + +export const getInstagramGraphClientForLoginType = ( + loginType: InstagramAuthValue["metadata"]["loginType"], +): InstagramHttpClient => + loginType === "unified" + ? instagramUnifiedGraphClient + : instagramBusinessClient + +export const getInstagramAccountNode = ( + auth: Pick, +): string => (auth.metadata.loginType === "unified" ? auth.metadata.igId : "me") + export const instagramCoexistGraphClient = new InstagramHttpClient({ baseUrl: API_URL, // 60s, matching `facebookCoexistGraphClient`: the coexist/contact-scan walk @@ -186,3 +210,9 @@ export const instagramCoexistGraphClient = new InstagramHttpClient({ timeout: 60_000, retries: 0, }) + +export const instagramUnifiedCoexistGraphClient = new InstagramHttpClient({ + baseUrl: "https://graph.facebook.com", + timeout: 60_000, + retries: 0, +}) diff --git a/integrations/instagram/src/schema.ts b/integrations/instagram/src/schema.ts index e70ae820bb..14f2e970ff 100644 --- a/integrations/instagram/src/schema.ts +++ b/integrations/instagram/src/schema.ts @@ -4,6 +4,7 @@ import { z } from "zod" export const INSTAGRAM_MESSAGE_METADATA = "SENT_FROM_CHATBOTX" export type InstagramConfig = Oauth2Config & { + unifiedLoginConfigId?: string verifyToken?: string version: string stateParams: { @@ -17,6 +18,9 @@ export type InstagramAuthValue = Oauth2AuthValue & { igName: string pageId: string version: string + loginType?: "direct" | "unified" + clientBusinessId?: string + username?: string } } diff --git a/packages/database/src/partials/credential.ts b/packages/database/src/partials/credential.ts index 7b942c3a6f..f133dd7c58 100644 --- a/packages/database/src/partials/credential.ts +++ b/packages/database/src/partials/credential.ts @@ -77,29 +77,45 @@ export type MessengerCredentialPublic = z.infer< typeof messengerCredentialPublicSchema > -export const instagramCredentialSchema = z.object({ +const instagramCredentialBaseSchema = z.object({ clientId: z.string(), version: z.string(), verifyToken: z.string(), clientSecret: z.string(), }) + +export const instagramCredentialSchema = instagramCredentialBaseSchema.extend({ + // Meta no longer offers Instagram Login to newly-created apps. When set, + // this is the Facebook Login for Business configuration used by Unified + // Login. Optional keeps every pre-Unified encrypted credential readable. + unifiedLoginConfigId: z.string().optional(), +}) export type InstagramCredential = z.infer export const instagramCredentialPublicSchema = instagramCredentialSchema.pick({ clientId: true, version: true, verifyToken: true, + unifiedLoginConfigId: true, }) export type InstagramCredentialPublic = z.infer< typeof instagramCredentialPublicSchema > -export const instagramFacebookCredentialSchema = instagramCredentialSchema -export type InstagramFacebookCredential = InstagramCredential +export const instagramFacebookCredentialSchema = instagramCredentialBaseSchema +export type InstagramFacebookCredential = z.infer< + typeof instagramFacebookCredentialSchema +> export const instagramFacebookCredentialPublicSchema = - instagramCredentialPublicSchema -export type InstagramFacebookCredentialPublic = InstagramCredentialPublic + instagramFacebookCredentialSchema.pick({ + clientId: true, + version: true, + verifyToken: true, + }) +export type InstagramFacebookCredentialPublic = z.infer< + typeof instagramFacebookCredentialPublicSchema +> export const threadsCredentialSchema = z.object({ clientId: z.string(), @@ -391,14 +407,17 @@ export const instagramCredentialUpdateSchema = z.object({ version: z.string().trim().min(1), verifyToken: z.string().trim().min(1), clientSecret: z.string().trim().min(1), + unifiedLoginConfigId: z.string().trim().optional(), }) export type InstagramCredentialUpdate = z.infer< typeof instagramCredentialUpdateSchema > export const instagramFacebookCredentialUpdateSchema = - instagramCredentialUpdateSchema -export type InstagramFacebookCredentialUpdate = InstagramCredentialUpdate + instagramCredentialUpdateSchema.omit({ unifiedLoginConfigId: true }) +export type InstagramFacebookCredentialUpdate = z.infer< + typeof instagramFacebookCredentialUpdateSchema +> export const threadsCredentialUpdateSchema = z.object({ clientId: z.string().trim().min(1), From ef21ea2a62e0882ef721f0fccf518c9500f80b5d Mon Sep 17 00:00:00 2001 From: gitpascal <44255902+gitpascal@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:43:50 +0100 Subject: [PATCH 2/2] fix(instagram): include redirect URI in Unified token exchange --- integrations/instagram/__tests__/unified-login.test.ts | 4 ++++ integrations/instagram/src/apis/auth.ts | 2 ++ integrations/instagram/src/integration.ts | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/integrations/instagram/__tests__/unified-login.test.ts b/integrations/instagram/__tests__/unified-login.test.ts index 078b8fcd03..f8c81ea44c 100644 --- a/integrations/instagram/__tests__/unified-login.test.ts +++ b/integrations/instagram/__tests__/unified-login.test.ts @@ -60,6 +60,9 @@ describe("Instagram Unified Login", () => { expect(params.get("client_id")).toBe("meta-app-id") expect(params.get("client_secret")).toBe("meta-app-secret") expect(params.get("code")).toBe("authorization-code") + expect(params.get("redirect_uri")).toBe( + "https://chatbotx.example/integrations/instagram/callback", + ) return HttpResponse.json({ access_token: ACCESS_TOKEN }) }), http.get(`${GRAPH_URL}/${VERSION}/me`, ({ request }) => { @@ -78,6 +81,7 @@ describe("Instagram Unified Login", () => { version: VERSION, }, "authorization-code", + "https://chatbotx.example/integrations/instagram/callback", ), ).resolves.toEqual({ accessToken: ACCESS_TOKEN, diff --git a/integrations/instagram/src/apis/auth.ts b/integrations/instagram/src/apis/auth.ts index 063cb2f217..cefbd036a7 100644 --- a/integrations/instagram/src/apis/auth.ts +++ b/integrations/instagram/src/apis/auth.ts @@ -74,6 +74,7 @@ export function generateUnifiedAuthUrl({ export function exchangeUnifiedCodeForToken( settings: { clientId: string; clientSecret: string; version?: string }, code: string, + redirectUrl: string, ): Promise { const version = settings.version ?? "v22.0" const endpoint = `${version}/oauth/access_token` @@ -85,6 +86,7 @@ export function exchangeUnifiedCodeForToken( client_id: settings.clientId, client_secret: settings.clientSecret, code, + redirect_uri: redirectUrl, }, }) const business = await instagramUnifiedGraphClient.get<{ diff --git a/integrations/instagram/src/integration.ts b/integrations/instagram/src/integration.ts index a00010247a..40f73df3c7 100644 --- a/integrations/instagram/src/integration.ts +++ b/integrations/instagram/src/integration.ts @@ -66,7 +66,11 @@ const config: IntegrationDefinition< exchangeCode: async ({ code, callbackUrl, credential }) => { const config = credential as InstagramConfig if (config.unifiedLoginConfigId) { - const authorization = await exchangeUnifiedCodeForToken(config, code) + const authorization = await exchangeUnifiedCodeForToken( + config, + code, + callbackUrl, + ) return oauth2Auth( config, callbackUrl,