Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 (
<Card className={CONNECT_PICKER_CARD_CLASS}>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{t("actions.connectFeature", { feature: "Instagram" })}
</CardTitle>
</CardHeader>
<CardContent>
<SelectFacebookAccounts
items={items}
sessionId={sessionId}
workspaceId={resolved.workspace.id}
/>
</CardContent>
</Card>
)
}

const target = resolved.session.targets[0]
if (!target) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down Expand Up @@ -246,6 +247,11 @@ export function EditInstagramSettingsForm({
required
/>

<InputField
label="Unified Login Configuration ID"
name="unifiedLoginConfigId"
/>

<div className="flex items-center justify-between gap-2">
{publicConfig !== null && (
<DeleteCredentialDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const updateInstagramSettingAction = authActionClient
version: parsedInput.version,
verifyToken: parsedInput.verifyToken,
clientSecret: parsedInput.clientSecret,
unifiedLoginConfigId: parsedInput.unifiedLoginConfigId || undefined,
}

await platformCredentialService.upsert({
Expand Down
17 changes: 15 additions & 2 deletions integrations/instagram/__tests__/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest"

const mocks = vi.hoisted(() => ({
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")
Expand Down Expand Up @@ -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,
})
})
Expand Down
2 changes: 2 additions & 0 deletions integrations/instagram/__tests__/contact-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ vi.mock("../src/lib/http-client", () => ({
instagramBusinessClient: {
get: instagramGet,
},
getInstagramGraphClient: () => ({ get: instagramGet }),
getInstagramGraphClientForLoginType: () => ({ get: instagramGet }),
}))

const { fetchInstagramContactProfile } = await import(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock("../src/lib/http-client", async (importOriginal) => {
return {
...actual,
instagramBusinessClient: { get: mockGet },
getInstagramGraphClient: () => ({ get: mockGet }),
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ vi.mock("../src/lib/http-client", async (importOriginal) => {
return {
...actual,
instagramBusinessClient: { get: mockGet },
getInstagramGraphClientForLoginType: () => ({ get: mockGet }),
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
5 changes: 5 additions & 0 deletions integrations/instagram/__tests__/sync-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down
164 changes: 164 additions & 0 deletions integrations/instagram/__tests__/unified-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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")
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 }) => {
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",
"https://chatbotx.example/integrations/instagram/callback",
),
).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()
})
})
4 changes: 2 additions & 2 deletions integrations/instagram/src/apis/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,7 +28,7 @@ export const uploadAttachment = (
const endpoint = `${auth.metadata.version}/${auth.metadata.pageId}/message_attachments`

return rescue(endpoint, () =>
instagramBusinessClient.post<InstagramSendMessageResponse>(endpoint, {
getInstagramGraphClient(auth).post<InstagramSendMessageResponse>(endpoint, {
headers: {
Authorization: `Bearer ${auth.tokens.accessToken}`,
},
Expand Down
Loading