Skip to content

Commit f8b7e0b

Browse files
sung17Deathgiver
andauthored
fix(instagram-facebook): send private replies via the page node again (#1122)
sendPrivateReplyMessage posted to `{igId}/messages`, which Meta rejects with `(#3) Application does not have the capability to make this API call.` even when the app holds instagram_manage_messages, pages_messaging and Human Agent at Advanced Access. For Instagram via Facebook Login the `messages` edge only exists on the Page node, so every private reply on that channel failed: the automation's `text` and `AIAgent` replies, the first message of a `flow` reply, and the agent's manual private reply from the inbox. This is the second time the fix has been needed. #875 moved the endpoint to `pageId`, then #945 moved it back to green a stale test whose fixture carried no `pageId` at all — so the endpoint silently became `/undefined/messages` and the assertion on the IG node kept passing. The test is now the guard instead of the cause: the fixture carries a `pageId` distinct from `igId`, and a new case asserts the IG node is never called. Also guards a missing `pageId` explicitly rather than building `/undefined/messages`, which is the silent failure mode that hid the regression. Two adjacent error-mapping bugs surfaced while tracing this: - Code 3 was in neither Instagram mapper, so it fell through to the `type === "OAuthException"` fallback and was reported as AUTH_FAILED — pointing operators at a reconnect that could never help. It is a capability problem, so it now maps to PERMISSION_DENIED (permanent), as messenger already does. - instagram-facebook's isRevokedTokenError matched *any* OAuthException, including code 3, making the disconnect flow skip its remote teardown for unrelated errors. Narrowed to code 190 plus a revoked subcode, matching the Instagram Login variant, which excludes a bare 190 as ambiguous to avoid false-positive disconnects. Unrelated, found in the same log: the getProfile failure in received-message.ts logged under `error`, so pino dropped the stack trace. Keyed to `err`. Co-authored-by: Deathgiver <anonymous@users.noreply.github.com>
1 parent 674c90f commit f8b7e0b

10 files changed

Lines changed: 262 additions & 27 deletions

File tree

.agents/skills/fb-comment-automation/SKILL.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ Read it before non-trivial changes. This skill is the quick map + the traps.
108108
instead of disappearing behind a `logger.warn`. Never "fix" that back into an empty
109109
`{ messageIds: [] }` return.
110110

111+
10. **Instagram-via-Facebook private replies use the Page node, not the IG node.**
112+
`sendPrivateReplyMessage` (`integrations/instagram-facebook/src/apis/comment.ts`) must
113+
post to `/{pageId}/messages`. `/{igId}/messages` returns `(#3) Application does not
114+
have the capability to make this API call.` even with `instagram_manage_messages`,
115+
`pages_messaging` and Human Agent at Advanced Access — code 3 means "this edge does
116+
not exist on this node", so it is NOT an App-dashboard problem. Already regressed
117+
twice (#875 fixed it, #945 reverted it to green a stale test whose fixture had no
118+
`pageId`, making the URL `/undefined/messages`). It breaks every private reply on the
119+
channel: `text`, `AIAgent`, a `flow` reply's first message, and the agent's manual
120+
inbox private reply (which enters via `handlers/comment/outgoing-private-reply`, not
121+
the automation loop). Instagram Login is different on purpose — `me/messages` on
122+
`graph.instagram.com`. Keep the `send-private-reply.test.ts` guard that asserts the IG
123+
node is never called. Also note both Instagram packages log
124+
`module=integration-instagram`, so attribute production failures by request host, not
125+
module name.
126+
111127
## Adding a new filter option (recipe)
112128

113129
1. Add the field to `fbCommentOptionsSchema` (partials) + DB default in the schema file

apps/worker/src/integration/handlers/comment-automation/private-reply.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ export const PRIVATE_REPLY_TEXT_SENDERS: Record<
5252
instagram: (auth, commentId, text) =>
5353
sendInstagramLoginPrivateReply(auth as InstagramAuthValue, commentId, text),
5454
// Instagram via Facebook Login sends the private DM through the
55-
// {igId}/messages endpoint (Page/Business-asset token), addressing the
56-
// commenter by comment id.
55+
// {pageId}/messages endpoint (Page access token), addressing the commenter
56+
// by comment id — Meta exposes the `messages` edge only on the Page node for
57+
// this login type; the IG node answers with error #3.
5758
instagramFacebook: (auth, commentId, text) =>
5859
sendInstagramFacebookPrivateReply(
5960
auth as InstagramFacebookAuthValue,

apps/worker/src/integration/handlers/received-message.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1524,7 +1524,11 @@ const createNewContactAndContactInbox = async (props: {
15241524
}
15251525
} catch (error) {
15261526
logger.warn(
1527-
{ error, sourceId: incomingContact.sourceId, channel: inbox.channel },
1527+
{
1528+
err: error,
1529+
sourceId: incomingContact.sourceId,
1530+
channel: inbox.channel,
1531+
},
15281532
"detectContactAndConversation: getProfile failed, creating contact without profile data",
15291533
)
15301534
// No `contactId` — the contact does not exist yet at this point.

docs/fb-comment-automation.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,31 @@ send), and the comment handler owns the channel routing.
200200
you add a new filter, add a skip log too — otherwise production debugging is blind
201201
(`processCommentAutomation` returns `void`, so BullMQ always records `returnValue: null`
202202
regardless of what happened).
203+
- **Instagram-via-Facebook private replies go through the Page node, never the IG node.**
204+
`sendPrivateReplyMessage`
205+
([`integrations/instagram-facebook/src/apis/comment.ts`](../integrations/instagram-facebook/src/apis/comment.ts))
206+
must post to `/{pageId}/messages`. Meta exposes the `messages` edge only on the Page for
207+
this login type; `/{igId}/messages` is rejected with `(#3) Application does not have the
208+
capability to make this API call.` even when the app holds `instagram_manage_messages`,
209+
`pages_messaging` and Human Agent at **Advanced Access** — code 3 means "this edge does
210+
not exist here", not "permission missing", so chasing it in the App dashboard is a dead
211+
end. This has regressed twice ([#875](https://github.com/ChatbotXIO/ChatbotX/pull/875)
212+
moved it to `pageId`; [#945](https://github.com/ChatbotXIO/ChatbotX/pull/945) moved it
213+
back to satisfy a stale test whose fixture had no `pageId`, so the endpoint silently
214+
became `/undefined/messages`). The blast radius is every private reply on that channel —
215+
automation `text`, `AIAgent`, the first message of a `flow` reply, **and** the agent's
216+
manual private reply from the inbox, which enters through
217+
`handlers/comment/outgoing-private-reply` instead of the automation loop. The Instagram
218+
Login variant is different on purpose: it posts to `me/messages` on
219+
`graph.instagram.com`. `send-private-reply.test.ts` now pins the node and asserts the IG
220+
node is never called — do not "simplify" that away.
221+
- **The two Instagram packages log under the same module name.** Both
222+
`integrations/instagram/src/lib/logger.ts` and
223+
`integrations/instagram-facebook/src/lib/logger.ts` call
224+
`getChildLogger("integration-instagram")`, so `module=integration-instagram` in
225+
production does **not** tell you which login type failed. Use the request host
226+
(`graph.facebook.com` = via Facebook, `graph.instagram.com` = Instagram Login) or the
227+
stack trace path instead.
203228

204229
## Testing
205230

integrations/instagram-facebook/README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ Each message is stamped with `metadata: "SENT_FROM_CHATBOTX"` so echo events can
119119
| `hideComment` | `POST /<version>/<commentId>?hide=true\|false` | |
120120
| `likeComment` | `POST /<version>/<igId>/likes` | Toggle via POST/DELETE |
121121
| `editComment` | — | No-op; Facebook API does not support editing comments |
122+
| `sendPrivateReply(Message)` | `POST /<version>/<pageId>/messages` | `recipient: { comment_id }`. **Page** node — see below |
123+
124+
Messaging edges use the **Page** node (`<pageId>/messages`, `<pageId>/message_attachments`, `me/messages`);
125+
Instagram content edges use the **IG** node (`<igId>/media`, `<igId>/stories`, `<igId>/likes`). Posting a
126+
private reply to `<igId>/messages` fails with `(#3) Application does not have the capability to make this
127+
API call.` regardless of granted permissions, because Meta does not expose that edge on the IG node for
128+
Facebook-Login connections. The Instagram Login package (`integrations/instagram`) uses `me/messages` on
129+
`graph.instagram.com` instead.
122130
123131
---
124132
@@ -128,16 +136,22 @@ All API errors are normalised by `mapToChannelError()` into a typed `ChannelErro
128136
129137
| Category | Trigger |
130138
|---|---|
131-
| `AUTH_FAILED` | Error code 190 / `OAuthException` type |
139+
| `AUTH_FAILED` | Error code 190; `OAuthException` type as a last-resort fallback |
132140
| `RATE_LIMITED` | Codes 4, 17, 613; subcode 2207051 |
133141
| `QUOTA_EXCEEDED` | Code 9; subcodes 2018028, 2207042 |
134142
| `USER_BLOCKED` | Code 551; subcode 1545041 |
135-
| `PERMISSION_DENIED` | Codes 10, 24, 25, 368; codes 200–299; subcode 2207050 |
143+
| `PERMISSION_DENIED` | Codes 3, 10, 24, 25, 368; codes 200–299; subcode 2207050 |
136144
| `PAYLOAD_INVALID` | Codes 1, 100, 352, 9004, 9007, 36000–36004; subcodes 2207020, 2207052 |
137145
| `NETWORK_ERROR` | Codes −1, −2 |
138146
| `INVALID_RECIPIENT` | Subcode 2018001 |
139147
140-
`isRevokedTokenError(error)` returns `true` for error code 190 or `type === "OAuthException"`, triggering the upstream re-auth flow.
148+
Code 3 (`Application does not have the capability to make this API call`) arrives as an
149+
`OAuthException` but is a capability/endpoint problem, not a token problem — it is mapped to
150+
`PERMISSION_DENIED` so it is treated as permanent and never sends the operator off to reconnect.
151+
152+
`isRevokedTokenError(error)` returns `true` only for error code 190 **with** a revoked subcode
153+
(458, 460, 463, 467), triggering the upstream re-auth flow. A bare 190 is ambiguous and is
154+
deliberately excluded to avoid false-positive disconnects, matching `integrations/instagram`.
141155
142156
---
143157
@@ -181,3 +195,9 @@ src/
181195
## API version
182196
183197
Default: `v23.0` (`DEFAULT_API_VERSION` in `constants.ts`). The version is stored in `auth.metadata.version` so it can be pinned per integration instance.
198+
199+
> **Known inconsistency:** `apis/page.ts` (`sendInstagramMessage`, the `messenger_profile` helpers)
200+
> reads the version off `auth.version`, which the connect flow never writes — it only writes
201+
> `auth.metadata.version`. Those calls therefore always fall back to `DEFAULT_API_VERSION`, while
202+
> `apis/comment.ts` and `apis/attachment.ts` (which read `auth.metadata.version`) use the version the
203+
> UI configured. `integrations/instagram` reads `metadata.version` everywhere and is unaffected.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ChannelErrorCategory } from "@chatbotx.io/sdk"
2+
import { describe, expect, test } from "vitest"
3+
import { InstagramException } from "../src/exception"
4+
import { isRevokedTokenError, mapToChannelError } from "../src/lib/error-mapper"
5+
6+
// `(#3) Application does not have the capability to make this API call.` — Meta
7+
// sends it as an OAuthException, but it is a capability/endpoint problem, not a
8+
// token problem. Before this was mapped, it fell through to the
9+
// `type === "OAuthException"` fallback and was reported as AUTH_FAILED, which
10+
// pointed operators at a reconnect that could never help.
11+
describe("instagram-facebook error-mapper — code 3 (capability)", () => {
12+
const capabilityError = () =>
13+
new InstagramException(
14+
"#(3) Application does not have the capability to make this API call.",
15+
400,
16+
3,
17+
null,
18+
"OAuthException",
19+
)
20+
21+
test("maps to PERMISSION_DENIED, not AUTH_FAILED", () => {
22+
const mapped = mapToChannelError(capabilityError())
23+
24+
expect(mapped.category).toBe(ChannelErrorCategory.PERMISSION_DENIED)
25+
})
26+
27+
test("is permanent, so callers never retry it", () => {
28+
const mapped = mapToChannelError(capabilityError())
29+
30+
expect(mapped.isPermanent).toBe(true)
31+
expect(mapped.isRetryable).toBe(false)
32+
})
33+
34+
test("is not treated as a revoked token", () => {
35+
expect(isRevokedTokenError(capabilityError())).toBe(false)
36+
})
37+
})
38+
39+
describe("instagram-facebook error-mapper — revoked token detection", () => {
40+
test("code 190 with a revoked subcode is a revoked token", () => {
41+
const exc = new InstagramException(
42+
"Error validating access token",
43+
401,
44+
190,
45+
463,
46+
"OAuthException",
47+
)
48+
49+
expect(isRevokedTokenError(exc)).toBe(true)
50+
})
51+
52+
// Ambiguous on purpose: Meta also emits bare 190 for transient session
53+
// problems, and treating those as revoked caused false-positive disconnects.
54+
test("code 190 without a subcode is not a revoked token", () => {
55+
const exc = new InstagramException(
56+
"Error validating access token",
57+
401,
58+
190,
59+
null,
60+
"OAuthException",
61+
)
62+
63+
expect(isRevokedTokenError(exc)).toBe(false)
64+
})
65+
66+
test("a non-Instagram error is never a revoked token", () => {
67+
expect(isRevokedTokenError(new Error("boom"))).toBe(false)
68+
})
69+
})

integrations/instagram-facebook/__tests__/send-private-reply.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,28 @@ import type { InstagramAuthValue } from "../src/schemas"
66

77
const ACCESS_TOKEN = "IG_TOKEN"
88
const IG_ID = "ig-business-account-id"
9+
const PAGE_ID = "facebook-page-id"
910
const COMMENT_ID = "comment-123"
11+
const MISSING_PAGE_ID_ERROR = /pageId/
1012

11-
const auth = {
13+
// `pageId` and `igId` are deliberately different values: the old fixture only
14+
// carried `igId`, so it could not tell the two nodes apart and let the #945
15+
// regression (posting to the IG node) look correct.
16+
const auth: InstagramAuthValue = {
1217
tokens: { accessToken: ACCESS_TOKEN },
13-
metadata: { igId: IG_ID, version: DEFAULT_API_VERSION },
14-
} as unknown as InstagramAuthValue
18+
metadata: {
19+
igId: IG_ID,
20+
igName: "ig-name",
21+
pageId: PAGE_ID,
22+
version: DEFAULT_API_VERSION,
23+
},
24+
} as InstagramAuthValue
1525

1626
describe("sendPrivateReply", () => {
17-
test("addresses the account directly via igId, not the me alias", async () => {
27+
test("addresses the Page node, since Meta exposes the messages edge there", async () => {
1828
server.use(
1929
http.post(
20-
`${API_URL}/${DEFAULT_API_VERSION}/${IG_ID}/messages`,
30+
`${API_URL}/${DEFAULT_API_VERSION}/${PAGE_ID}/messages`,
2131
async ({ request }) => {
2232
expect(request.headers.get("authorization")).toBe(
2333
`Bearer ${ACCESS_TOKEN}`,
@@ -38,4 +48,36 @@ describe("sendPrivateReply", () => {
3848
sendPrivateReply(auth, COMMENT_ID, "Hello from Instagram via Facebook"),
3949
).resolves.toEqual({ recipient_id: "recipient-1" })
4050
})
51+
52+
// Regression guard for #875 → #945: the IG node returns `(#3) Application
53+
// does not have the capability to make this API call.` for this login type,
54+
// so a send that reaches it is broken even though the request looks sane.
55+
test("never posts to the IG node", async () => {
56+
let igNodeCalled = false
57+
58+
server.use(
59+
http.post(`${API_URL}/${DEFAULT_API_VERSION}/${IG_ID}/messages`, () => {
60+
igNodeCalled = true
61+
return HttpResponse.json({ recipient_id: "wrong-node" })
62+
}),
63+
http.post(`${API_URL}/${DEFAULT_API_VERSION}/${PAGE_ID}/messages`, () =>
64+
HttpResponse.json({ recipient_id: "recipient-1" }),
65+
),
66+
)
67+
68+
await sendPrivateReply(auth, COMMENT_ID, "Hello")
69+
70+
expect(igNodeCalled).toBe(false)
71+
})
72+
73+
test("throws instead of posting to /undefined/messages when pageId is missing", async () => {
74+
const authWithoutPageId = {
75+
tokens: { accessToken: ACCESS_TOKEN },
76+
metadata: { igId: IG_ID, version: DEFAULT_API_VERSION },
77+
} as unknown as InstagramAuthValue
78+
79+
await expect(
80+
sendPrivateReply(authWithoutPageId, COMMENT_ID, "Hello"),
81+
).rejects.toThrow(MISSING_PAGE_ID_ERROR)
82+
})
4183
})

integrations/instagram-facebook/src/apis/comment.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DEFAULT_API_VERSION } from "../constants"
2-
import { rescue } from "../exception"
2+
import { InstagramException, rescue } from "../exception"
33
import { instagramGraphClient } from "../lib/http-client"
44
import {
55
INSTAGRAM_MESSAGE_METADATA,
@@ -66,27 +66,55 @@ export const hideComment = (
6666
/**
6767
* Sends a private DM reply to the author of a comment with an arbitrary
6868
* message payload (text, attachment, quick replies, …) — used by flow-based
69-
* private replies to deliver the *first* outgoing message of the run,
70-
* addressing the Instagram business account directly via igId rather than the
71-
* Page node (Meta's Messenger Platform private-reply endpoint also accepts
72-
* `/<IG_ID>/messages` — see
73-
* https://developers.facebook.com/docs/messenger-platform/instagram/features/private-replies).
74-
* The comment_id-anchored Send API bypasses the normal messaging-window
75-
* requirement.
69+
* private replies to deliver the *first* outgoing message of the run, and by
70+
* the inbox's manual private reply. The comment_id-anchored Send API bypasses
71+
* the normal messaging-window requirement.
72+
*
73+
* Addresses the **Page** node, not the IG business account. For Instagram via
74+
* Facebook Login (Page access token on graph.facebook.com) Meta only exposes
75+
* the `messages` edge on the Page:
76+
* https://developers.facebook.com/docs/messenger-platform/instagram/features/private-replies
77+
* Posting to `/<IG_ID>/messages` is rejected with `(#3) Application does not
78+
* have the capability to make this API call.` even when the app holds
79+
* `instagram_manage_messages`, `pages_messaging` and Human Agent at Advanced
80+
* Access — the code means "this edge does not exist here", not "permission
81+
* missing". That matches the rest of this package: messaging edges use the
82+
* Page node (`{pageId}/message_attachments`, `me/messages`) while IG content
83+
* edges use the IG node (`{igId}/media`, `{igId}/likes`), and it matches
84+
* messenger's identical `sendPrivateReplyMessage` (`{pageId}/messages`).
85+
*
86+
* DO NOT "fix" this back to `igId`. That has already shipped twice: #875 moved
87+
* it to `pageId`, then #945 moved it back to make a stale test green (the test
88+
* fixture had no `pageId`, so the endpoint silently became `/undefined/…`),
89+
* which broke every private reply in production again. The Instagram Login
90+
* variant is different on purpose — it uses `me/messages` on
91+
* graph.instagram.com.
7692
*
7793
* Stamps `message.metadata` like every other Instagram send path so the
7894
* message_echo webhook (`handlers/webhook.ts`) recognizes and skips our own
7995
* echo instead of re-ingesting it as an incoming message.
8096
*/
81-
export const sendPrivateReplyMessage = (
97+
// `async` so the pageId guard below rejects the returned promise instead of
98+
// throwing synchronously — callers await it, and a sync throw would escape a
99+
// `.catch()` attached to the result.
100+
export const sendPrivateReplyMessage = async (
82101
auth: InstagramAuthValue,
83102
commentId: string,
84103
message: InstagramSendMessage | InstagramMessageAttachmentPayload,
85104
): Promise<InstagramSendMessageResponse> => {
86105
const version = auth.metadata.version ?? DEFAULT_API_VERSION
87-
const endpoint = `${version}/${auth.metadata.igId}/messages`
106+
const pageId = auth.metadata.pageId
107+
// Without this the endpoint becomes `/undefined/messages`, which Meta
108+
// answers with a generic error that hides the real cause — exactly how the
109+
// #875 → #945 regression went unnoticed.
110+
if (!pageId) {
111+
throw new InstagramException(
112+
"Cannot send an Instagram private reply: the integration has no pageId. Reconnect the Instagram account.",
113+
)
114+
}
115+
const endpoint = `${version}/${pageId}/messages`
88116

89-
return rescue(endpoint, () =>
117+
return await rescue(endpoint, () =>
90118
instagramGraphClient.post<InstagramSendMessageResponse>(endpoint, {
91119
headers: {
92120
"Content-Type": "application/json",

integrations/instagram-facebook/src/lib/error-mapper.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const AUTH_FAILED_CODES = new Set([
2828
])
2929

3030
const PERMISSION_DENIED_CODES = new Set([
31+
// "Application does not have the capability to make this API call" — the app
32+
// lacks a permission/feature, or the edge does not exist on the node being
33+
// addressed. Never retryable. Mirrors messenger's mapper.
34+
3,
3135
10, // Permission denied (FB Graph)
3236
24, // Permission error (IG Content Publishing)
3337
25, // IG account restricted/checkpointed
@@ -163,13 +167,35 @@ function mapApiFields(fields: ChannelErrorSource): ChannelError {
163167
}
164168

165169
// === Revoked / invalidated access token detection ===
166-
// Facebook returns error code 190 (OAuthException) when a page access token is
167-
// expired or revoked. Returning true triggers the upstream re-auth flow.
170+
// Facebook signals revoked/expired page tokens via OAuthException + code 190.
171+
// Sub-codes: 458 = app not installed, 460 = password changed,
172+
// 463 = access token expired, 467 = invalid access token.
173+
// Code 190 with no subcode is ambiguous and is NOT treated as revoked, to
174+
// avoid false-positive channel disconnects. Mirrors the Instagram Login
175+
// variant (`integrations/instagram`), which already gates this way.
176+
//
177+
// Matching on `type === "OAuthException"` alone (as this did before) was wrong:
178+
// Meta uses that type for unrelated failures such as code 3 ("Application does
179+
// not have the capability to make this API call"), so a plain endpoint or
180+
// capability error was reported as a revoked token and made the disconnect flow
181+
// skip its remote teardown.
182+
const REVOKED_TOKEN_SUBCODES = new Set([458, 460, 463, 467])
183+
168184
export function isRevokedTokenError(error: unknown): boolean {
169-
if (error instanceof InstagramException) {
170-
return error.code === 190 || error.type === "OAuthException"
185+
if (!(error instanceof InstagramException)) {
186+
return false
187+
}
188+
189+
const mappedError = mapToChannelError(error)
190+
if (mappedError.subCode === null || mappedError.subCode === undefined) {
191+
return false
171192
}
172-
return false
193+
194+
return (
195+
mappedError.category === ChannelErrorCategory.AUTH_FAILED &&
196+
mappedError.code === 190 &&
197+
REVOKED_TOKEN_SUBCODES.has(Number(mappedError.subCode))
198+
)
173199
}
174200

175201
export function mapToChannelError(rawError: unknown): ChannelError {

0 commit comments

Comments
 (0)