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,