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, }, }) }