Skip to content

Commit 9e1fd81

Browse files
author
Deathgiver
committed
feat: new Instagram connect
1 parent 9fad512 commit 9e1fd81

11 files changed

Lines changed: 145 additions & 163 deletions

File tree

apps/builder/src/app/(no-sidebar)/channels/instagram/select/page.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getUserInstagramAccounts } from "@chatbotx.io/integration-instagram"
1+
import { getInstagramAccount } from "@chatbotx.io/integration-instagram"
22
import { cookies } from "next/headers"
33
import { redirect } from "next/navigation"
44
import { SelectAccount } from "@/features/integration-instagram/components/select-accounts"
@@ -19,7 +19,11 @@ export default async function InstagramSelectPage() {
1919
redirect("/channels/create")
2020
}
2121

22-
const accounts = await getUserInstagramAccounts(auth.userToken, auth.version)
22+
const account = await getInstagramAccount(auth.userToken, auth.version)
2323

24-
return <SelectAccount accounts={accounts} workspaceId={auth.workspaceId} />
24+
if (!account) {
25+
redirect("/channels/create")
26+
}
27+
28+
return <SelectAccount account={account} workspaceId={auth.workspaceId} />
2529
}

apps/builder/src/app/integrations/[...integration]/callback.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export const handleCallback = async (
167167
safeReferer,
168168
).toString()
169169

170-
const userToken = await exchangeInstagramCode(
170+
const { accessToken: userToken } = await exchangeInstagramCode(
171171
instagramCredential.config,
172172
code,
173173
callbackUrl,

apps/builder/src/features/integration-instagram/actions/select-account.action.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { integrationInstagramModel } from "@chatbotx.io/database/schema"
1313
import type { UserModel } from "@chatbotx.io/database/types"
1414
import type { InstagramAuthValue } from "@chatbotx.io/integration-instagram"
1515
import {
16-
exchangeLongLivedToken,
1716
integration as integrationInstagram,
1817
subscribePageToInstagramWebhook,
1918
} from "@chatbotx.io/integration-instagram"
@@ -64,10 +63,7 @@ export const selectAccountAction = authActionClient
6463

6564
const { createdWorkspace, brandingCtx } = await db.transaction(
6665
async (tx) => {
67-
const longLivedToken = await exchangeLongLivedToken(
68-
instagramSettings,
69-
parsedInput.accessToken,
70-
)
66+
const longLivedToken = parsedInput.accessToken
7167

7268
let createdWorkspace = false
7369
if (!workspaceId) {
@@ -90,7 +86,7 @@ export const selectAccountAction = authActionClient
9086
})
9187

9288
await subscribePageToInstagramWebhook({
93-
pageId: parsedInput.pageId,
89+
igId: parsedInput.igId,
9490
accessToken: longLivedToken,
9591
version: instagramSettings.version,
9692
})

apps/builder/src/features/integration-instagram/components/instagram-accounts.tsx

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,26 @@
22

33
import type { InstagramAccount } from "@chatbotx.io/integration-instagram"
44
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
5-
import { RadioGroupField } from "@chatbotx.io/ui/components/form/radio-group-field"
65
import { Button } from "@chatbotx.io/ui/components/ui/button"
76
import { Form } from "@chatbotx.io/ui/components/ui/form"
87
import { zodResolver } from "@hookform/resolvers/zod"
98
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
109
import { Loader2Icon } from "lucide-react"
10+
import Image from "next/image"
1111
import Link from "next/link"
1212
import { useRouter } from "next/navigation"
1313
import { useTranslations } from "next-intl"
14-
import { useEffect } from "react"
15-
import { useWatch } from "react-hook-form"
1614
import { toast } from "sonner"
1715
import { selectAccountAction } from "../actions/select-account.action"
1816
import { selectAccountRequest } from "../schemas/action"
1917

2018
export function InstagramAccounts({
2119
workspaceId,
22-
accounts,
20+
account,
2321
onSuccess,
2422
}: {
2523
workspaceId?: string | null
26-
accounts: InstagramAccount[]
24+
account: InstagramAccount
2725
onSuccess?: () => void
2826
}) {
2927
const t = useTranslations()
@@ -37,11 +35,11 @@ export function InstagramAccounts({
3735
mode: "onChange",
3836
defaultValues: {
3937
workspaceId,
40-
igId: "",
41-
igName: "",
42-
igUsername: "",
43-
pageId: "",
44-
accessToken: "",
38+
igId: account.id,
39+
igName: account.name,
40+
igUsername: account.username,
41+
pageId: account.pageId,
42+
accessToken: account.pageAccessToken,
4543
},
4644
},
4745
actionProps: {
@@ -65,39 +63,31 @@ export function InstagramAccounts({
6563
},
6664
)
6765

68-
const { control, setValue } = form
69-
const watchedIgId = useWatch({ control, name: "igId" })
70-
useEffect(() => {
71-
const selectedAccount = accounts.find(
72-
(account) => account.id === watchedIgId,
73-
)
74-
75-
setValue("accessToken", selectedAccount?.pageAccessToken ?? "")
76-
setValue("igName", selectedAccount?.name ?? "")
77-
setValue("igUsername", selectedAccount?.username ?? "")
78-
setValue("pageId", selectedAccount?.pageId ?? "")
79-
}, [watchedIgId, setValue, accounts])
80-
8166
return (
8267
<Form {...form}>
8368
<form className="space-y-6" onSubmit={handleSubmitWithAction}>
8469
<div className="hidden">
70+
<InputField name="igId" type="hidden" />
8571
<InputField name="accessToken" type="hidden" />
8672
<InputField name="igName" type="hidden" />
8773
<InputField name="igUsername" type="hidden" />
8874
<InputField name="pageId" type="hidden" />
8975
</div>
9076

91-
<div className="mt-2">
92-
<RadioGroupField
93-
label={t("instagram.selectInstagramAccount")}
94-
name="igId"
95-
options={accounts.map((account) => ({
96-
value: account.id,
97-
label: `${account.name} (@${account.username})`,
98-
}))}
99-
required
100-
/>
77+
<div className="flex items-center gap-3 rounded-lg border p-4">
78+
{account.profile_picture_url && (
79+
<Image
80+
alt={account.name}
81+
className="size-12 rounded-full object-cover"
82+
height={48}
83+
src={account.profile_picture_url}
84+
width={48}
85+
/>
86+
)}
87+
<div>
88+
<p className="font-medium">{account.name}</p>
89+
<p className="text-muted-foreground text-sm">@{account.username}</p>
90+
</div>
10191
</div>
10292

10393
<div className="flex justify-end gap-2">
@@ -108,10 +98,7 @@ export function InstagramAccounts({
10898
{t("actions.cancel")}
10999
</Link>
110100
</Button>
111-
<Button
112-
disabled={!form.formState.isValid || form.formState.isSubmitting}
113-
type="submit"
114-
>
101+
<Button disabled={form.formState.isSubmitting} type="submit">
115102
{form.formState.isSubmitting && (
116103
<Loader2Icon className="animate-spin" />
117104
)}

apps/builder/src/features/integration-instagram/components/select-accounts.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import {
1010
import { useTranslations } from "next-intl"
1111
import { InstagramAccounts } from "@/features/integration-instagram/components/instagram-accounts"
1212

13-
type SelectAccountProps = {
14-
accounts: InstagramAccount[]
13+
export type SelectAccountProps = {
14+
account: InstagramAccount
1515
workspaceId: string
1616
}
1717

18-
export function SelectAccount({ accounts, workspaceId }: SelectAccountProps) {
18+
export function SelectAccount({ account, workspaceId }: SelectAccountProps) {
1919
const t = useTranslations()
2020

2121
return (
@@ -26,7 +26,7 @@ export function SelectAccount({ accounts, workspaceId }: SelectAccountProps) {
2626
</CardTitle>
2727
</CardHeader>
2828
<CardContent>
29-
<InstagramAccounts accounts={accounts} workspaceId={workspaceId} />
29+
<InstagramAccounts account={account} workspaceId={workspaceId} />
3030
</CardContent>
3131
</Card>
3232
)

apps/builder/src/lib/facebook-pending-auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type FacebookAuthCallback = {
1010
referer: string
1111
version: string
1212
expiresAt: number
13+
igUserId?: string
1314
}
1415

1516
export async function encryptAuth(data: unknown): Promise<string> {
Lines changed: 45 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import { DEFAULT_API_VERSION } from "../constants"
1+
import { DEFAULT_API_VERSION, INSTAGRAM_BUSINESS_SCOPES } from "../constants"
22
import { rescue } from "../exception"
3-
import { instagramGraphClient } from "../lib/http-client"
3+
import {
4+
instagramBusinessClient,
5+
instagramOAuthClient,
6+
} from "../lib/http-client"
47

5-
const FACEBOOK_OAUTH_BASE = "https://www.facebook.com"
6-
7-
const INSTAGRAM_SCOPES = [
8-
"instagram_basic",
9-
"instagram_manage_messages",
10-
"pages_manage_metadata",
11-
"pages_show_list",
12-
"pages_messaging",
13-
"pages_read_engagement",
14-
"business_management",
15-
]
8+
const INSTAGRAM_OAUTH_AUTHORIZE_URL =
9+
"https://www.instagram.com/oauth/authorize"
1610

1711
export type InstagramAccount = {
1812
id: string
@@ -23,23 +17,8 @@ export type InstagramAccount = {
2317
pageAccessToken: string
2418
}
2519

26-
type FacebookPageWithIg = {
27-
id: string
28-
name: string
29-
access_token: string
30-
instagram_business_account?: { id: string }
31-
}
32-
33-
type InstagramUserResponse = {
34-
id: string
35-
name: string
36-
username: string
37-
profile_picture_url?: string
38-
}
39-
4020
export function generateAuthUrl({
4121
clientId,
42-
version = DEFAULT_API_VERSION,
4322
redirectUrl,
4423
stateParams,
4524
}: {
@@ -51,92 +30,67 @@ export function generateAuthUrl({
5130
const params = new URLSearchParams({
5231
client_id: clientId,
5332
redirect_uri: redirectUrl,
54-
scope: INSTAGRAM_SCOPES.join(","),
5533
response_type: "code",
5634
state: Buffer.from(JSON.stringify(stateParams ?? {})).toString("base64"),
35+
scope: INSTAGRAM_BUSINESS_SCOPES.join(","),
5736
})
58-
return `${FACEBOOK_OAUTH_BASE}/${version}/dialog/oauth?${params.toString()}`
37+
return `${INSTAGRAM_OAUTH_AUTHORIZE_URL}?${params.toString()}`
5938
}
6039

40+
// Step 1: exchange authorization code → short-lived user access token + user ID
6141
export function exchangeCodeForToken(
6242
settings: { clientId: string; clientSecret: string; version?: string },
6343
code: string,
6444
redirectUrl: string,
65-
): Promise<string> {
66-
const { version = DEFAULT_API_VERSION } = settings
67-
const endpoint = `${version}/oauth/access_token`
45+
): Promise<{ accessToken: string; userId: string }> {
46+
const endpoint = "oauth/access_token"
6847

6948
return rescue(endpoint, async () => {
70-
const res: { access_token: string } = await instagramGraphClient.get(
71-
endpoint,
72-
{
73-
searchParams: {
49+
const res: { access_token: string; user_id: string | number } =
50+
await instagramOAuthClient.post(endpoint, {
51+
body: new URLSearchParams({
7452
client_id: settings.clientId,
7553
client_secret: settings.clientSecret,
7654
redirect_uri: redirectUrl,
7755
code,
78-
},
79-
},
80-
)
81-
return res.access_token
56+
grant_type: "authorization_code",
57+
}),
58+
})
59+
return { accessToken: res.access_token, userId: String(res.user_id) }
8260
})
8361
}
8462

85-
export async function getUserInstagramAccounts(
63+
export async function getInstagramAccount(
8664
userAccessToken: string,
87-
version: string = DEFAULT_API_VERSION,
88-
): Promise<InstagramAccount[]> {
89-
const pagesEndpoint = `${version}/me/accounts`
65+
version = DEFAULT_API_VERSION,
66+
): Promise<InstagramAccount | null> {
67+
const endpoint = `${version}/me`
9068

91-
const pagesRes = await rescue(pagesEndpoint, async () => {
92-
const res: { data: FacebookPageWithIg[] } = await instagramGraphClient.get(
93-
pagesEndpoint,
94-
{
69+
try {
70+
const res = await rescue(endpoint, async () =>
71+
instagramBusinessClient.get<{
72+
id: string
73+
username: string
74+
name?: string
75+
profile_picture_url?: string
76+
account_type?: string
77+
}>(endpoint, {
9578
searchParams: {
96-
fields: "id,name,access_token,instagram_business_account",
79+
fields: "id,username,name,profile_picture_url,account_type",
9780
access_token: userAccessToken,
9881
},
99-
},
82+
}),
10083
)
101-
return res.data
102-
})
103-
104-
const pagesWithIg = pagesRes.filter((page) => page.instagram_business_account)
105-
106-
const accounts: (InstagramAccount | null)[] = await Promise.all(
107-
pagesWithIg.map(async (page): Promise<InstagramAccount | null> => {
108-
const igId = page.instagram_business_account?.id
109-
if (!igId) {
110-
return null
111-
}
112-
113-
const igEndpoint = `${version}/${igId}`
114-
try {
115-
const igRes: InstagramUserResponse = await rescue(
116-
igEndpoint,
117-
async () =>
118-
instagramGraphClient.get<InstagramUserResponse>(igEndpoint, {
119-
searchParams: {
120-
fields: "id,name,username,profile_picture_url",
121-
access_token: page.access_token,
122-
},
123-
}),
124-
)
125-
return {
126-
id: igRes.id,
127-
name: igRes.name,
128-
username: igRes.username,
129-
profile_picture_url: igRes.profile_picture_url,
130-
pageId: page.id,
131-
pageAccessToken: page.access_token,
132-
}
133-
} catch {
134-
return null
135-
}
136-
}),
137-
)
13884

139-
return accounts.filter(
140-
(account): account is InstagramAccount => account !== null,
141-
)
85+
return {
86+
id: res.id,
87+
name: res.name ?? res.username,
88+
username: res.username,
89+
profile_picture_url: res.profile_picture_url,
90+
pageId: res.id,
91+
pageAccessToken: userAccessToken,
92+
}
93+
} catch {
94+
return null
95+
}
14296
}

0 commit comments

Comments
 (0)