diff --git a/apps/builder/__tests__/messenger-select-account.test.tsx b/apps/builder/__tests__/messenger-select-account.test.tsx
new file mode 100644
index 0000000000..c55f590707
--- /dev/null
+++ b/apps/builder/__tests__/messenger-select-account.test.tsx
@@ -0,0 +1,94 @@
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
+
+/** Echoes the key back so assertions never depend on the English copy. */
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}))
+
+const { mockMessengerPages } = vi.hoisted(() => ({
+ mockMessengerPages: vi.fn((_props: { items: unknown[] }) => null),
+}))
+
+// The picker list is unit-tested in `messenger-pages.test.tsx`; here it is a
+// capture stub so this file can assert what `SelectPage` hands it.
+vi.mock("@/features/integration-messenger/components/messenger-pages", () => ({
+ MessengerPages: mockMessengerPages,
+}))
+
+const { SelectPage } = await import(
+ "@/features/integration-messenger/components/select-account"
+)
+
+describe("SelectPage", () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ container = document.createElement("div")
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ })
+
+ const readErrorBox = () => container.querySelector('[role="alert"]')
+
+ test("shows Meta's own sentence in a box when the page list failed with a Graph error", async () => {
+ await act(() => {
+ root.render(
+ ,
+ )
+ })
+
+ expect(readErrorBox()?.textContent).toContain(
+ "(#1) Please reduce the amount of data you're asking for",
+ )
+ // The picker still renders, empty, so its own "No Facebook Pages found"
+ // state is what the operator sees under the box.
+ expect(mockMessengerPages).toHaveBeenCalledWith(
+ expect.objectContaining({ items: [] }),
+ undefined,
+ )
+ })
+
+ test("shows the generic copy when the failure carried no Graph message", async () => {
+ await act(() => {
+ root.render(
+ ,
+ )
+ })
+
+ expect(readErrorBox()?.textContent).toContain(
+ "messenger.selectPage.loadFailed",
+ )
+ })
+
+ test("renders no error box when the page list loaded", async () => {
+ await act(() => {
+ root.render(
+ ,
+ )
+ })
+
+ expect(readErrorBox()).toBeNull()
+ })
+})
diff --git a/apps/builder/__tests__/messenger-select-page.test.tsx b/apps/builder/__tests__/messenger-select-page.test.tsx
index 7e4b29df22..0fb825024e 100644
--- a/apps/builder/__tests__/messenger-select-page.test.tsx
+++ b/apps/builder/__tests__/messenger-select-page.test.tsx
@@ -7,12 +7,16 @@ import { beforeEach, describe, expect, test, vi } from "vitest"
const {
mockFindConnectedMessengerPageIds,
mockGetUserPages,
+ mockLoggerError,
+ mockMapToChannelError,
mockReadPendingAuth,
mockRedirect,
mockSelectPage,
} = vi.hoisted(() => ({
mockFindConnectedMessengerPageIds: vi.fn(),
mockGetUserPages: vi.fn(),
+ mockLoggerError: vi.fn(),
+ mockMapToChannelError: vi.fn(),
mockReadPendingAuth: vi.fn(),
mockRedirect: vi.fn((path: string) => {
throw new Error(`redirect:${path}`)
@@ -37,6 +41,11 @@ vi.mock("@chatbotx.io/business", () => ({
vi.mock("@chatbotx.io/integration-messenger", () => ({
getUserPages: mockGetUserPages,
+ mapToChannelError: mockMapToChannelError,
+}))
+
+vi.mock("@/lib/log", () => ({
+ logger: { error: mockLoggerError },
}))
vi.mock("@/lib/facebook-pending-auth", () => ({
@@ -57,6 +66,7 @@ const { default: MessengerSelectPage } = await import(
)
type SelectPageElementProps = {
+ loadError?: { providerMessage?: string }
items: Array<{
id: string
isAlreadyConnected: boolean
@@ -186,6 +196,69 @@ describe("MessengerSelectPage", () => {
expect(connectable?.secondary).toBe("page-connectable")
})
+ // Meta answers `/me/accounts` with `{"error":{"code":1,"message":"Please
+ // reduce the amount of data you're asking for…"}}` for some users. That
+ // must render the picker's empty state with the provider's reason, not
+ // the route-level "Something went wrong" boundary.
+ test("renders an empty picker with the channel error message when Graph fails to list pages", async () => {
+ const graphError = new Error("graph exploded")
+ mockGetUserPages.mockRejectedValue(graphError)
+ mockMapToChannelError.mockReturnValue({
+ message: "(#1) Please reduce the amount of data you're asking for",
+ code: 1,
+ category: "unknown",
+ })
+
+ const element = await MessengerSelectPage()
+
+ if (!isValidElement(element)) {
+ throw new Error("MessengerSelectPage did not return a valid element")
+ }
+
+ expect(mockMapToChannelError).toHaveBeenCalledWith(graphError)
+ expect(element.props.items).toEqual([])
+ expect(element.props.loadError).toEqual({
+ providerMessage:
+ "(#1) Please reduce the amount of data you're asking for",
+ })
+ expect(mockFindConnectedMessengerPageIds).not.toHaveBeenCalled()
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.objectContaining({ err: graphError }),
+ expect.any(String),
+ )
+ })
+
+ // No Graph body to quote (timeout, DNS, malformed JSON): the mapper reports
+ // UNKNOWN_ERROR's -1 code, so the UI must fall back to its own copy rather
+ // than surface "Unknown error." or a raw network message as Meta's words.
+ test("omits providerMessage when the failure carries no Graph error code", async () => {
+ mockGetUserPages.mockRejectedValue(new Error("fetch failed"))
+ mockMapToChannelError.mockReturnValue({
+ message: "fetch failed",
+ code: -1,
+ category: "unknown",
+ })
+
+ const element = await MessengerSelectPage()
+
+ if (!isValidElement(element)) {
+ throw new Error("MessengerSelectPage did not return a valid element")
+ }
+
+ expect(element.props.items).toEqual([])
+ expect(element.props.loadError).toEqual({ providerMessage: undefined })
+ })
+
+ test("passes no loadError when Graph lists pages successfully", async () => {
+ const element = await MessengerSelectPage()
+
+ if (!isValidElement(element)) {
+ throw new Error("MessengerSelectPage did not return a valid element")
+ }
+
+ expect(element.props.loadError).toBeUndefined()
+ })
+
test("redirects to channel creation when the pending-auth cookie is missing or invalid", async () => {
mockReadPendingAuth.mockResolvedValue(null)
diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json
index d0f85e62c3..a30dd13794 100644
--- a/apps/builder/messages/ar.json
+++ b/apps/builder/messages/ar.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "أنت لست مسؤولاً عن هذه الصفحة",
"bmLookupFailedTitle": "قد تكون بعض صفحات مدير الأعمال غير ظاهرة",
"bmLookupFailedDescription": "أعد ربط Messenger وامنح صلاحية الوصول إلى مدير الأعمال حتى يمكن عرض الصفحات المُدارة من خلاله.",
+ "loadFailed": "تعذّر تحميل صفحات فيسبوك الخاصة بك",
"alreadyConnectedNote": "هذه الصفحة مرتبطة بالفعل",
"notAdminNote": "أنت لست مسؤولاً عن هذه الصفحة",
"tryAgain": "محاولة إعادة الاتصال"
diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json
index 603b5d762a..3c7d34d08f 100644
--- a/apps/builder/messages/da.json
+++ b/apps/builder/messages/da.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Du er ikke administrator for denne side",
"bmLookupFailedTitle": "Business Manager pages may være missing",
"bmLookupFailedDescription": "Forbind igen Messenger og grant Business Manager access so pages managed through a Business Manager kan være listed.",
+ "loadFailed": "Kunne ikke indlæse dine Facebook-sider",
"alreadyConnectedNote": "Denne page er allerede forbundet",
"notAdminNote": "Du er ikke administrator for denne side",
"tryAgain": "Try reconnecting"
diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json
index 1253f06c88..f35cce1446 100644
--- a/apps/builder/messages/de.json
+++ b/apps/builder/messages/de.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Sie sind kein Administrator dieser Seite",
"bmLookupFailedTitle": "Business-Manager-Seiten fehlen möglicherweise",
"bmLookupFailedDescription": "Verbinden Sie Messenger erneut und gewähren Sie Zugriff auf den Business Manager, damit über ihn verwaltete Seiten aufgelistet werden können.",
+ "loadFailed": "Deine Facebook-Seiten konnten nicht geladen werden",
"alreadyConnectedNote": "Diese Seite ist bereits verbunden",
"notAdminNote": "Sie sind kein Administrator dieser Seite",
"tryAgain": "Erneute Verbindung versuchen"
diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json
index 6a4dd439ac..d198abef44 100644
--- a/apps/builder/messages/en.json
+++ b/apps/builder/messages/en.json
@@ -3574,6 +3574,7 @@
"noConnectablePagesDescription": "You are not an admin of this Page",
"bmLookupFailedTitle": "Business Manager pages may be missing",
"bmLookupFailedDescription": "Reconnect Messenger and grant Business Manager access so pages managed through a Business Manager can be listed.",
+ "loadFailed": "Couldn't load your Facebook Pages",
"alreadyConnectedNote": "This page is already connected",
"notAdminNote": "You are not an admin of this Page",
"tryAgain": "Try reconnecting"
diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json
index 1a38901cbc..6ea96d620d 100644
--- a/apps/builder/messages/es.json
+++ b/apps/builder/messages/es.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "No eres administrador de esta página",
"bmLookupFailedTitle": "Empresa Manager páginas may be missing",
"bmLookupFailedDescription": "ReConectar Messenger y grant Empresa Manager access so páginas managed through un Empresa Manager puede be listed.",
+ "loadFailed": "No se pudieron cargar tus páginas de Facebook",
"alreadyConnectedNote": "This página es already connected",
"notAdminNote": "No eres administrador de esta página",
"tryAgain": "Intenta volver a conectar"
diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json
index ce7ef117ee..4d479f9465 100644
--- a/apps/builder/messages/fi.json
+++ b/apps/builder/messages/fi.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Et ole tämän sivun ylläpitäjä",
"bmLookupFailedTitle": "Business Manager -sivuja saattaa puuttua",
"bmLookupFailedDescription": "Yhdistä Messenger uudelleen ja myönnä Business Manager -käyttöoikeus, jotta Business Managerin kautta hallinnoidut sivut voidaan näyttää.",
+ "loadFailed": "Facebook-sivujesi lataaminen epäonnistui",
"alreadyConnectedNote": "Tämä sivu on jo yhdistetty",
"notAdminNote": "Et ole tämän sivun ylläpitäjä",
"tryAgain": "Yritä yhdistää uudelleen"
diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json
index ef4f7776bd..3b4e08b3b2 100644
--- a/apps/builder/messages/fr.json
+++ b/apps/builder/messages/fr.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Vous n’êtes pas administrateur de cette Page",
"bmLookupFailedTitle": "Certaines Pages du Business Manager peuvent manquer",
"bmLookupFailedDescription": "Reconnectez Messenger et accordez l’accès au Business Manager afin de répertorier les Pages qu’il gère.",
+ "loadFailed": "Impossible de charger vos Pages Facebook",
"alreadyConnectedNote": "Cette Page est déjà connectée",
"notAdminNote": "Vous n’êtes pas administrateur de cette Page",
"tryAgain": "Réessayer la connexion"
diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json
index a7df682756..c750c73b84 100644
--- a/apps/builder/messages/he.json
+++ b/apps/builder/messages/he.json
@@ -1443,6 +1443,7 @@
"noConnectablePagesDescription": "אינך מנהל של דף זה",
"bmLookupFailedTitle": "ייתכן שחסרים דפי Business Manager",
"bmLookupFailedDescription": "חברו מחדש את Messenger והעניקו גישה ל-Business Manager כדי שניתן יהיה להציג דפים המנוהלים דרכו.",
+ "loadFailed": "לא ניתן לטעון את דפי הפייסבוק שלך",
"alreadyConnectedNote": "דף זה כבר מחובר",
"notAdminNote": "אינך מנהל של דף זה",
"tryAgain": "ניסיון חיבור מחדש"
diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json
index f79d09cf69..decd4c6d6d 100644
--- a/apps/builder/messages/id.json
+++ b/apps/builder/messages/id.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Anda bukan admin Halaman ini",
"bmLookupFailedTitle": "Halaman Business Manager mungkin tidak ditampilkan",
"bmLookupFailedDescription": "Hubungkan kembali Messenger dan berikan akses Business Manager agar halaman yang dikelola melalui Business Manager dapat ditampilkan.",
+ "loadFailed": "Tidak dapat memuat Halaman Facebook Anda",
"alreadyConnectedNote": "Halaman ini sudah terhubung",
"notAdminNote": "Anda bukan admin Halaman ini",
"tryAgain": "Coba hubungkan kembali"
diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json
index c66daaf6b2..3016ebf9b0 100644
--- a/apps/builder/messages/it.json
+++ b/apps/builder/messages/it.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Non sei amministratore di questa Pagina",
"bmLookupFailedTitle": "Alcune Pagine di Business Manager potrebbero non essere disponibili",
"bmLookupFailedDescription": "Ricollega Messenger e concedi l'accesso a Business Manager affinché possano essere elencate le Pagine gestite tramite Business Manager.",
+ "loadFailed": "Impossibile caricare le tue Pagine Facebook",
"alreadyConnectedNote": "Questa Pagina è già collegata",
"notAdminNote": "Non sei amministratore di questa Pagina",
"tryAgain": "Prova a ricollegarti"
diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json
index a74bd5c0e8..de0851b43c 100644
--- a/apps/builder/messages/ja.json
+++ b/apps/builder/messages/ja.json
@@ -3365,6 +3365,7 @@
"noConnectablePagesDescription": "あなたはこのページの管理者ではありません",
"bmLookupFailedTitle": "ビジネスマネージャのページが一部表示されていない可能性があります",
"bmLookupFailedDescription": "Messengerを再接続し、ビジネスマネージャへのアクセスを許可すると、ビジネスマネージャで管理されているページを一覧表示できます。",
+ "loadFailed": "Facebookページを読み込めませんでした",
"alreadyConnectedNote": "このページはすでに接続されています",
"notAdminNote": "あなたはこのページの管理者ではありません",
"tryAgain": "再接続を試す"
diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json
index a15db689c0..d4674d0936 100644
--- a/apps/builder/messages/nl.json
+++ b/apps/builder/messages/nl.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "U bent geen beheerder van deze pagina",
"bmLookupFailedTitle": "Pagina's uit Bedrijfsmanager ontbreken mogelijk",
"bmLookupFailedDescription": "Koppel Messenger opnieuw en verleen toegang tot Bedrijfsmanager zodat pagina's die via Bedrijfsmanager worden beheerd kunnen worden vermeld.",
+ "loadFailed": "Je Facebook-pagina's konden niet worden geladen",
"alreadyConnectedNote": "Deze pagina is al gekoppeld",
"notAdminNote": "U bent geen beheerder van deze pagina",
"tryAgain": "Probeer opnieuw te koppelen"
diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json
index 6d3e511696..c26dedefc7 100644
--- a/apps/builder/messages/pt-BR.json
+++ b/apps/builder/messages/pt-BR.json
@@ -3365,6 +3365,7 @@
"noConnectablePagesDescription": "Você não é administrador desta Página",
"bmLookupFailedTitle": "Algumas páginas do Gerenciador de Negócios podem não aparecer",
"bmLookupFailedDescription": "Reconecte o Messenger e conceda acesso ao Gerenciador de Negócios para que as páginas gerenciadas por ele possam ser listadas.",
+ "loadFailed": "Não foi possível carregar suas Páginas do Facebook",
"alreadyConnectedNote": "Esta página já está conectada",
"notAdminNote": "Você não é administrador desta Página",
"tryAgain": "Tentar reconectar"
diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json
index 68e51907ce..739f25c833 100644
--- a/apps/builder/messages/pt-PT.json
+++ b/apps/builder/messages/pt-PT.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Não é administrador desta Página",
"bmLookupFailedTitle": "Poderão faltar páginas do Gestor de Negócios",
"bmLookupFailedDescription": "Volte a ligar o Messenger e conceda acesso ao Gestor de Negócios para que as páginas geridas através dele possam ser apresentadas.",
+ "loadFailed": "Não foi possível carregar as suas Páginas do Facebook",
"alreadyConnectedNote": "Esta página já está ligada",
"notAdminNote": "Não é administrador desta Página",
"tryAgain": "Tentar ligar novamente"
diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json
index a264498f60..03ff65e138 100644
--- a/apps/builder/messages/ro.json
+++ b/apps/builder/messages/ro.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Nu ești administrator al acestei Pagini",
"bmLookupFailedTitle": "Este posibil să lipsească paginile din Business Manager",
"bmLookupFailedDescription": "Reconectează Messenger și acordă acces la Business Manager pentru ca paginile gestionate printr-un Business Manager să poată fi listate.",
+ "loadFailed": "Nu s-au putut încărca Paginile tale de Facebook",
"alreadyConnectedNote": "Această pagină este deja conectată",
"notAdminNote": "Nu ești administrator al acestei Pagini",
"tryAgain": "Încearcă să te reconectezi"
diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json
index e4827ad126..5cd7fce482 100644
--- a/apps/builder/messages/sv.json
+++ b/apps/builder/messages/sv.json
@@ -4136,6 +4136,7 @@
"selectPage": {
"alreadyConnectedNote": "Den här sidan är redan ansluten",
"bmLookupFailedDescription": "Återanslut Messenger och bevilja åtkomst till Business Manager så att sidor som hanteras där kan visas.",
+ "loadFailed": "Det gick inte att läsa in dina Facebook-sidor",
"bmLookupFailedTitle": "Business Manager-sidor kan saknas",
"noConnectablePagesDescription": "Du är inte administratör för den här sidan",
"noConnectablePagesTitle": "Inga sidor tillgängliga att ansluta",
diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json
index 1d289faffe..f083d10b60 100644
--- a/apps/builder/messages/tr.json
+++ b/apps/builder/messages/tr.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Bu Sayfanın yöneticisi değilsiniz",
"bmLookupFailedTitle": "İşletme Yöneticisi sayfaları eksik olabilir",
"bmLookupFailedDescription": "Bir İşletme Yöneticisi üzerinden yönetilen sayfaların listelenebilmesi için Messenger'ı yeniden bağlayın ve İşletme Yöneticisi erişimi verin.",
+ "loadFailed": "Facebook Sayfaların yüklenemedi",
"alreadyConnectedNote": "Bu sayfa zaten bağlı",
"notAdminNote": "Bu Sayfanın yöneticisi değilsiniz",
"tryAgain": "Yeniden bağlanmayı dene"
diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json
index 0a27a91602..a1ebc2c6d2 100644
--- a/apps/builder/messages/vi.json
+++ b/apps/builder/messages/vi.json
@@ -3386,6 +3386,7 @@
"noConnectablePagesDescription": "Bạn không phải là Admin của Page này",
"bmLookupFailedTitle": "Có thể thiếu trang trong Business Manager",
"bmLookupFailedDescription": "Hãy kết nối lại Messenger và cấp quyền Business Manager để liệt kê các trang được quản lý qua Business Manager.",
+ "loadFailed": "Không tải được danh sách Trang Facebook",
"alreadyConnectedNote": "Trang này đã được kết nối",
"notAdminNote": "Bạn không phải là Admin của Page này",
"tryAgain": "Kết nối lại"
diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json
index 3f6bb72dab..a69ab9a294 100644
--- a/apps/builder/messages/zh-CN.json
+++ b/apps/builder/messages/zh-CN.json
@@ -4136,6 +4136,7 @@
"selectPage": {
"alreadyConnectedNote": "该页面已连接",
"bmLookupFailedDescription": "重新连接Messenger 并授予商务管理平台访问权限,以便可以列出通过商务管理平台管理的页面。",
+ "loadFailed": "无法加载你的 Facebook 主页",
"bmLookupFailedTitle": "业务管理平台页面可能遗失",
"noConnectablePagesDescription": "您不是此页面的管理员",
"noConnectablePagesTitle": "没有可连接的页面",
diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json
index 40ace35c96..0f5f087611 100644
--- a/apps/builder/messages/zh-TW.json
+++ b/apps/builder/messages/zh-TW.json
@@ -3419,6 +3419,7 @@
"noConnectablePagesDescription": "您不是此頁面的管理員",
"bmLookupFailedTitle": "業務管理平台頁面可能遺失",
"bmLookupFailedDescription": "重新連接Messenger 並授予商務管理平台存取權限,以便可以列出透過商務管理平台管理的頁面。",
+ "loadFailed": "無法載入你的 Facebook 粉絲專頁",
"alreadyConnectedNote": "該頁面已連接",
"notAdminNote": "您不是此頁面的管理員",
"tryAgain": "嘗試重新連接"
diff --git a/apps/builder/src/app/(no-sidebar)/channels/messenger/select/page.tsx b/apps/builder/src/app/(no-sidebar)/channels/messenger/select/page.tsx
index c73d61ab28..087a0bbeb3 100644
--- a/apps/builder/src/app/(no-sidebar)/channels/messenger/select/page.tsx
+++ b/apps/builder/src/app/(no-sidebar)/channels/messenger/select/page.tsx
@@ -1,6 +1,10 @@
import { messengerIntegrationService } from "@chatbotx.io/business"
-import { getUserPages } from "@chatbotx.io/integration-messenger"
+import {
+ getUserPages,
+ mapToChannelError,
+} from "@chatbotx.io/integration-messenger"
import type { ConnectableFacebookPage } from "@chatbotx.io/integration-messenger/schema"
+import { UNKNOWN_ERROR } from "@chatbotx.io/sdk"
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
import {
@@ -9,11 +13,15 @@ import {
} from "@/features/channel-connect/lib/picker-items"
import { InboxIcon } from "@/features/inboxes/components/inbox-icon"
import type { MessengerPickerItem } from "@/features/integration-messenger/components/messenger-pages"
-import { SelectPage } from "@/features/integration-messenger/components/select-account"
+import {
+ type PagesLoadError,
+ SelectPage,
+} from "@/features/integration-messenger/components/select-account"
import {
FB_MESSENGER_PENDING_AUTH_COOKIE,
readPendingAuth,
} from "@/lib/facebook-pending-auth"
+import { logger } from "@/lib/log"
export const dynamic = "force-dynamic"
@@ -54,6 +62,55 @@ function toPickerItem(
}
}
+type UserPagesResult = Awaited> & {
+ loadError?: PagesLoadError
+}
+
+/**
+ * `mapToChannelError` keeps Graph's numeric `error.code` and falls back to
+ * `UNKNOWN_ERROR.code` when there was no Graph error body to read (timeout,
+ * DNS, malformed JSON). Any other code means the message is Meta's, not ours.
+ */
+function readProviderMessage(channelError: {
+ code: string | number
+ message: string
+}): string | undefined {
+ const hasGraphCode = channelError.code !== UNKNOWN_ERROR.code
+ return hasGraphCode && channelError.message ? channelError.message : undefined
+}
+
+/**
+ * Graph sometimes refuses `/me/accounts` outright — e.g.
+ * `{"error":{"code":1,"message":"Please reduce the amount of data you're
+ * asking for, then retry your request"}}` for users with many pages. That is
+ * the user's Facebook state, not a bug in this route, so it renders as an
+ * empty picker carrying Meta's sentence instead of tripping the route error
+ * boundary ("Something went wrong").
+ */
+async function loadUserPages(
+ userToken: string,
+ version: string,
+): Promise {
+ try {
+ return await getUserPages(userToken, version)
+ } catch (error) {
+ const channelError = mapToChannelError(error)
+ logger.error(
+ {
+ err: error,
+ code: channelError.code,
+ category: channelError.category,
+ },
+ "Failed to list Facebook Pages for Messenger connect",
+ )
+ return {
+ pages: [],
+ bmLookupFailed: false,
+ loadError: { providerMessage: readProviderMessage(channelError) },
+ }
+ }
+}
+
export default async function MessengerSelectPage() {
const pendingAuth = await readPendingAuth(FB_MESSENGER_PENDING_AUTH_COOKIE)
@@ -61,11 +118,22 @@ export default async function MessengerSelectPage() {
redirect("/channels/create")
}
- const { pages, bmLookupFailed } = await getUserPages(
+ const { pages, bmLookupFailed, loadError } = await loadUserPages(
pendingAuth.userToken,
pendingAuth.version,
)
+ if (loadError !== undefined) {
+ return (
+
+ )
+ }
+
const connectedPageIds =
await messengerIntegrationService.findConnectedPageIds(
pages.map((page) => page.id),
diff --git a/apps/builder/src/features/integration-messenger/components/select-account.tsx b/apps/builder/src/features/integration-messenger/components/select-account.tsx
index 17b011703e..68bdca6b3e 100644
--- a/apps/builder/src/features/integration-messenger/components/select-account.tsx
+++ b/apps/builder/src/features/integration-messenger/components/select-account.tsx
@@ -16,15 +16,33 @@ import { CONNECT_PICKER_CARD_CLASS } from "@/features/channel-connect/components
import type { MessengerPickerItem } from "@/features/integration-messenger/components/messenger-pages"
import { MessengerPages } from "@/features/integration-messenger/components/messenger-pages"
+export type PagesLoadError = {
+ /**
+ * Meta's own sentence (via `mapToChannelError`, which already folds
+ * `error_user_msg` in). Absent when Graph never answered — timeout, DNS,
+ * a malformed body — so the UI falls back to its generic copy, the same
+ * split `throwWhatsappApiActionError` makes for WhatsApp.
+ */
+ providerMessage?: string
+}
+
type SelectPageProps = {
items: MessengerPickerItem[]
bmLookupFailed: boolean
+ /**
+ * Set when `/me/accounts` failed outright. `items` is empty in that case,
+ * so the picker's own "No Facebook Pages found" state renders under a red
+ * box carrying the failure. Meta's sentence wins; the generic copy is only
+ * for failures where Facebook never answered.
+ */
+ loadError?: PagesLoadError
workspaceId: string
}
export function SelectPage({
items,
bmLookupFailed,
+ loadError,
workspaceId,
}: SelectPageProps) {
const t = useTranslations()
@@ -37,6 +55,14 @@ export function SelectPage({
+ {loadError !== undefined && (
+
+
+ {loadError.providerMessage ??
+ t("messenger.selectPage.loadFailed")}
+
+
+ )}
{bmLookupFailed && (
diff --git a/integrations/messenger/__tests__/get-user-pages.test.ts b/integrations/messenger/__tests__/get-user-pages.test.ts
index e6a6be5160..a4b8acdc37 100644
--- a/integrations/messenger/__tests__/get-user-pages.test.ts
+++ b/integrations/messenger/__tests__/get-user-pages.test.ts
@@ -7,7 +7,10 @@ import {
vi,
} from "vitest"
-vi.mock("../src/lib/http-client", () => ({
+// Only the network client is stubbed; the pure error predicates stay real so
+// the page-size backoff below exercises the same rule production uses.
+vi.mock("../src/lib/http-client", async (importOriginal) => ({
+ ...(await importOriginal()),
facebookGraphClient: {
get: vi.fn(),
},
@@ -20,6 +23,7 @@ vi.mock("../src/lib/logger", () => ({
// Dynamic imports ensure vi.mock is fully applied before loading these modules.
const { getUserPages } = await import("../src/apis/auth")
const { facebookGraphClient } = await import("../src/lib/http-client")
+const { MessengerAPIException } = await import("../src/exception")
const mockGet = facebookGraphClient.get as MockInstance
@@ -131,7 +135,7 @@ describe("getUserPages", () => {
])
})
- test("requests page fields with limit=100 and the user token", async () => {
+ test("requests page fields with limit=50 and the user token", async () => {
mockGet.mockResolvedValueOnce({ data: [directPage] })
await getUserPages("user-token")
@@ -140,8 +144,146 @@ describe("getUserPages", () => {
searchParams: expect.objectContaining({
fields: "id,name,access_token,category,tasks",
access_token: "user-token",
- limit: "100",
+ limit: "50",
}),
})
})
+ // Meta answers `/me/accounts` with `{"error":{"code":1,"message":"Please
+ // reduce the amount of data you're asking for, then retry your request"}}`
+ // for users whose pages carry too much data per response. The fix is a
+ // smaller page, and because the cursor from a failed walk is not reusable,
+ // every retry starts again from the first page.
+ describe("page-size backoff on Graph error code 1", () => {
+ const dataTooLarge = () =>
+ new MessengerAPIException(
+ "Please reduce the amount of data you're asking for, then retry your request",
+ 500,
+ 1,
+ null,
+ undefined,
+ {
+ httpStatus: 500,
+ errorBody: {
+ error: {
+ code: 1,
+ message:
+ "Please reduce the amount of data you're asking for, then retry your request",
+ },
+ },
+ },
+ )
+
+ const requestedLimits = () =>
+ mockGet.mock.calls.map((call) => call[1].searchParams.limit)
+
+ test("shrinks 50 → 25 → 10 and restarts from the first page", async () => {
+ mockGet
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockResolvedValueOnce({ data: [directPage] })
+
+ const result = await getUserPages("user-token")
+
+ expect(result.pages).toEqual([{ ...directPage, isConnectable: true }])
+ expect(requestedLimits()).toEqual(["50", "25", "10"])
+ for (const call of mockGet.mock.calls) {
+ expect(call[1].searchParams).not.toHaveProperty("after")
+ }
+ })
+
+ test("drops pages already read when a later page fails, so nothing is duplicated", async () => {
+ const directPage2 = { ...directPage, id: "page-direct-2" }
+ mockGet
+ .mockResolvedValueOnce({
+ data: [directPage],
+ paging: {
+ cursors: { after: "c1" },
+ next: "https://graph.facebook.com/v23.0/me/accounts?after=c1",
+ },
+ })
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockResolvedValueOnce({ data: [directPage, directPage2] })
+
+ const result = await getUserPages("user-token")
+
+ expect(result.pages.map((page) => page.id)).toEqual([
+ "page-direct",
+ "page-direct-2",
+ ])
+ expect(requestedLimits()).toEqual(["50", "50", "25"])
+ expect(mockGet.mock.calls[1][1].searchParams.after).toBe("c1")
+ expect(mockGet.mock.calls[2][1].searchParams).not.toHaveProperty("after")
+ })
+
+ test("keeps the 2000-page ceiling when the page shrinks, instead of stopping after 20 requests", async () => {
+ const TOTAL_PAGES = 250 // > 20 requests × 10 per page
+ const pageAt = (index: number) => ({
+ ...directPage,
+ id: `page-${index}`,
+ })
+ mockGet
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockImplementation((_endpoint: string, options) => {
+ const offset = Number(options.searchParams.after ?? 0)
+ const size = Number(options.searchParams.limit)
+ const next = offset + size
+ return Promise.resolve({
+ data: Array.from({ length: size }, (_, i) => pageAt(offset + i)),
+ paging:
+ next < TOTAL_PAGES
+ ? {
+ cursors: { after: String(next) },
+ next: "https://graph.facebook.com/next",
+ }
+ : undefined,
+ })
+ })
+
+ const result = await getUserPages("user-token")
+
+ expect(result.pages).toHaveLength(TOTAL_PAGES)
+ expect(
+ requestedLimits()
+ .slice(2)
+ .every((limit) => limit === "10"),
+ ).toBe(true)
+ expect(mockGet).toHaveBeenCalledTimes(2 + TOTAL_PAGES / 10)
+ })
+
+ test("throws the Graph error once the smallest page size also fails", async () => {
+ mockGet
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockRejectedValueOnce(dataTooLarge())
+ .mockRejectedValueOnce(dataTooLarge())
+
+ await expect(getUserPages("user-token")).rejects.toMatchObject({
+ code: 1,
+ message: expect.stringContaining("reduce the amount of data"),
+ })
+ expect(requestedLimits()).toEqual(["50", "25", "10"])
+ })
+
+ test("does not shrink for any other Graph error", async () => {
+ mockGet.mockRejectedValueOnce(
+ new MessengerAPIException("Invalid OAuth access token", 400, 190, 467),
+ )
+
+ await expect(getUserPages("user-token")).rejects.toBeInstanceOf(
+ MessengerAPIException,
+ )
+ expect(mockGet).toHaveBeenCalledTimes(1)
+ })
+
+ test("does not shrink for the generic code-1 'API Unknown' failure", async () => {
+ mockGet.mockRejectedValueOnce(
+ new MessengerAPIException("An unknown error occurred", 500, 1, null),
+ )
+
+ await expect(getUserPages("user-token")).rejects.toBeInstanceOf(
+ MessengerAPIException,
+ )
+ expect(mockGet).toHaveBeenCalledTimes(1)
+ })
+ })
})
diff --git a/integrations/messenger/__tests__/http-client-policy-error.test.ts b/integrations/messenger/__tests__/http-client-policy-error.test.ts
index 255812cd1e..f84329de56 100644
--- a/integrations/messenger/__tests__/http-client-policy-error.test.ts
+++ b/integrations/messenger/__tests__/http-client-policy-error.test.ts
@@ -1,6 +1,12 @@
+import { HTTPError, type NormalizedOptions } from "ky"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { MessengerAPIException, rescue } from "../src/exception"
-import { isExpectedPolicyError, logChannelError } from "../src/lib/http-client"
+import {
+ isDataTooLargeGraphError,
+ isExpectedPolicyError,
+ logChannelError,
+ shouldRetryGraphRequest,
+} from "../src/lib/http-client"
import { logger } from "../src/lib/logger"
vi.mock("../src/lib/logger", () => ({
@@ -36,6 +42,94 @@ describe("isExpectedPolicyError", () => {
})
})
+describe("isDataTooLargeGraphError", () => {
+ const sentence =
+ "Please reduce the amount of data you're asking for, then retry your request"
+
+ test("code 1, no subcode, with Meta's 'reduce the amount of data' sentence", () => {
+ expect(isDataTooLargeGraphError({ code: 1, message: sentence })).toBe(true)
+ expect(
+ isDataTooLargeGraphError({
+ code: "1",
+ subCode: null,
+ message: `(#1) ${sentence}`,
+ }),
+ ).toBe(true)
+ })
+
+ test("generic code 1 ('API Unknown', documented as transient) is not", () => {
+ expect(
+ isDataTooLargeGraphError({
+ code: 1,
+ message: "An unknown error occurred",
+ }),
+ ).toBe(false)
+ expect(isDataTooLargeGraphError({ code: 1 })).toBe(false)
+ })
+
+ test("a subcode, or any other code, is not", () => {
+ expect(
+ isDataTooLargeGraphError({ code: 1, subCode: 99, message: sentence }),
+ ).toBe(false)
+ expect(isDataTooLargeGraphError({ code: 2, message: sentence })).toBe(false)
+ expect(isDataTooLargeGraphError({})).toBe(false)
+ })
+})
+
+// Meta's "Please reduce the amount of data you're asking for" comes back as a
+// 5xx, which the client would otherwise retry three times — and the same
+// request never succeeds on retry. The only remedy is a smaller page, which
+// the caller (`fetchDirectPages`) handles, so the client must give up at once.
+describe("shouldRetryGraphRequest", () => {
+ const httpError = (status: number, body: unknown) => {
+ const error = new HTTPError(
+ new Response(JSON.stringify(body), { status }),
+ new Request("https://graph.facebook.com/v23.0/me/accounts"),
+ {} as NormalizedOptions,
+ )
+ error.data = body
+ return error
+ }
+
+ test("refuses to retry a code-1 'reduce the amount of data' 500", async () => {
+ const error = httpError(500, {
+ error: {
+ code: 1,
+ message:
+ "Please reduce the amount of data you're asking for, then retry your request",
+ },
+ })
+
+ await expect(
+ shouldRetryGraphRequest({ error, retryCount: 1 }),
+ ).resolves.toBe(false)
+ })
+
+ test("leaves every other failure to ky's default status-code policy", async () => {
+ await expect(
+ shouldRetryGraphRequest({
+ error: httpError(500, { error: { code: 2, message: "Service down" } }),
+ retryCount: 1,
+ }),
+ ).resolves.toBeUndefined()
+ // Generic code 1 is Meta's transient "API Unknown": still retried.
+ await expect(
+ shouldRetryGraphRequest({
+ error: httpError(500, {
+ error: { code: 1, message: "An unknown error occurred" },
+ }),
+ retryCount: 1,
+ }),
+ ).resolves.toBeUndefined()
+ await expect(
+ shouldRetryGraphRequest({
+ error: new Error("socket hang up"),
+ retryCount: 1,
+ }),
+ ).resolves.toBeUndefined()
+ })
+})
+
describe("logChannelError", () => {
test("logs an expected policy error (code 230) at warn, not error", () => {
logChannelError(
diff --git a/integrations/messenger/src/apis/auth.ts b/integrations/messenger/src/apis/auth.ts
index 445a9f4efc..b0102824ea 100644
--- a/integrations/messenger/src/apis/auth.ts
+++ b/integrations/messenger/src/apis/auth.ts
@@ -1,7 +1,10 @@
import { fetchAllCursorPages } from "@chatbotx.io/utils"
import { DEFAULT_API_VERSION } from "../constants"
-import { rescue } from "../exception"
-import { facebookGraphClient } from "../lib/http-client"
+import { MessengerAPIException, rescue } from "../exception"
+import {
+ facebookGraphClient,
+ isDataTooLargeGraphError,
+} from "../lib/http-client"
import { logger } from "../lib/logger"
import type { ConnectableFacebookPage, FacebookPage } from "../schema"
@@ -15,6 +18,20 @@ type SourcedFacebookPage = {
const MAX_PAGES = 20
const GRAPH_PAGE_LIMIT = 100
+/**
+ * Page sizes tried for `/me/accounts`, largest first. Meta rejects a page
+ * whose pages carry too much data with error code 1 ("Please reduce the
+ * amount of data you're asking for, then retry your request"); each retry
+ * asks for less. 10 is the floor — below that the error is reported as-is.
+ */
+const DIRECT_PAGES_PAGE_LIMITS = [50, 25, 10] as const
+/**
+ * Most Facebook Pages one walk of `/me/accounts` will read, whatever the page
+ * size — the same ceiling the previous fixed `limit=100 × 20 pages` gave, so
+ * shrinking the page never shortens the list a user used to see.
+ */
+const DIRECT_PAGES_MAX_ROWS = GRAPH_PAGE_LIMIT * MAX_PAGES
+const DIRECT_PAGES_FIELDS = "id,name,access_token,category,tasks"
const BUSINESS_PAGE_BATCH_SIZE = 5
const ADMIN_PAGE_TASKS = [
"ADVERTISE",
@@ -28,6 +45,10 @@ function fetchAllPages(
endpoint: string,
fields: string,
accessToken: string,
+ pagination: { limit: number; maxPages: number } = {
+ limit: GRAPH_PAGE_LIMIT,
+ maxPages: MAX_PAGES,
+ },
): Promise {
return fetchAllCursorPages({
endpoint,
@@ -37,11 +58,59 @@ function fetchAllPages(
// method reference directly detaches it and crashes at call time.
get: (pageEndpoint, options) =>
facebookGraphClient.get(pageEndpoint, options),
- limit: GRAPH_PAGE_LIMIT,
- maxPages: MAX_PAGES,
+ limit: pagination.limit,
+ maxPages: pagination.maxPages,
})
}
+/**
+ * Error code 1 with no subcode is Meta's "response too large" reply. The
+ * http client deliberately does not retry it (`shouldRetryGraphRequest`) —
+ * the identical request never succeeds — so the only remedy is a smaller page.
+ */
+function isDataTooLargeException(
+ error: unknown,
+): error is MessengerAPIException {
+ return (
+ error instanceof MessengerAPIException && isDataTooLargeGraphError(error)
+ )
+}
+
+/**
+ * Walks `/me/accounts` with progressively smaller pages. A retry always
+ * starts over from the first page: the cursor that came with a failed walk
+ * was minted for the old page size and cannot be resumed, and starting over
+ * is what keeps rows read before the failure from appearing twice.
+ */
+async function fetchDirectPages(
+ endpoint: string,
+ accessToken: string,
+): Promise {
+ let lastError: unknown
+
+ for (const limit of DIRECT_PAGES_PAGE_LIMITS) {
+ try {
+ return await fetchAllPages(
+ endpoint,
+ DIRECT_PAGES_FIELDS,
+ accessToken,
+ { limit, maxPages: Math.ceil(DIRECT_PAGES_MAX_ROWS / limit) },
+ )
+ } catch (error) {
+ if (!isDataTooLargeException(error)) {
+ throw error
+ }
+ lastError = error
+ logger.warn(
+ { limit, code: error.code },
+ "Graph rejected /me/accounts page size, retrying from the first page with a smaller one",
+ )
+ }
+ }
+
+ throw lastError
+}
+
function getUserBusinesses(
userAccessToken: string,
version: string,
@@ -439,11 +508,7 @@ export async function getUserPages(
): Promise<{ pages: ConnectableFacebookPage[]; bmLookupFailed: boolean }> {
const directPagesEndpoint = `${version}/me/accounts`
const directPages = await rescue(directPagesEndpoint, () =>
- fetchAllPages(
- directPagesEndpoint,
- "id,name,access_token,category,tasks",
- userAccessToken,
- ),
+ fetchDirectPages(directPagesEndpoint, userAccessToken),
)
// const businessPagesResult = await getBusinessManagedPages(
diff --git a/integrations/messenger/src/lib/http-client.ts b/integrations/messenger/src/lib/http-client.ts
index f1f9821ab9..4c1ae8fbbc 100644
--- a/integrations/messenger/src/lib/http-client.ts
+++ b/integrations/messenger/src/lib/http-client.ts
@@ -1,5 +1,5 @@
import { UNKNOWN_ERROR } from "@chatbotx.io/sdk"
-import ky, { isHTTPError, type KyInstance } from "ky"
+import ky, { isHTTPError, type KyInstance, type ShouldRetryState } from "ky"
import {
type ChannelErrorSource,
MessengerAPIException,
@@ -30,6 +30,47 @@ export function isExpectedPolicyError(
)
}
+/**
+ * Meta's "Please reduce the amount of data you're asking for, then retry your
+ * request" reply. It shares error code 1 with the generic "API Unknown"
+ * failure that Meta documents as transient ("wait and retry"), so the code
+ * alone is not enough: only the combination of code 1, no subcode and this
+ * sentence identifies it. It arrives as a 5xx, but re-sending the identical
+ * request never succeeds — only a smaller page does — so it is excluded from
+ * the client's status-code retry and left to the caller (`fetchDirectPages`
+ * in `apis/auth.ts`) to shrink the page.
+ *
+ * @see https://developers.facebook.com/docs/graph-api/guides/error-handling
+ */
+const GRAPH_DATA_TOO_LARGE_CODE = 1
+const GRAPH_DATA_TOO_LARGE_MESSAGE = /reduce the amount of data/i
+
+export function isDataTooLargeGraphError(
+ source: Pick,
+): boolean {
+ return (
+ Number(source.code) === GRAPH_DATA_TOO_LARGE_CODE &&
+ (source.subCode === null || source.subCode === undefined) &&
+ GRAPH_DATA_TOO_LARGE_MESSAGE.test(source.message ?? "")
+ )
+}
+
+/**
+ * `false` short-circuits ky's retry; `undefined` defers to its default
+ * status-code policy. Only the request shape decides — `error.data` is
+ * already populated when ky calls this.
+ */
+export function shouldRetryGraphRequest({
+ error,
+}: Pick): Promise<
+ boolean | undefined
+> {
+ if (isHTTPError(error) && isDataTooLargeGraphError(parseOriginError(error))) {
+ return Promise.resolve(false)
+ }
+ return Promise.resolve(undefined)
+}
+
function sanitizeRequestUrl(url: string | undefined): string | undefined {
if (!url) {
return
@@ -102,6 +143,7 @@ class MessengerHttpClient {
methods: ["get", "post", "put", "delete"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
backoffLimit: config.retryDelay ?? 1000,
+ shouldRetry: shouldRetryGraphRequest,
},
})
}