|
1 | 1 | import { DEFAULT_API_VERSION } from "../constants" |
2 | | -import { rescue } from "../exception" |
| 2 | +import { InstagramException, rescue } from "../exception" |
3 | 3 | import { instagramGraphClient } from "../lib/http-client" |
4 | 4 | import { |
5 | 5 | INSTAGRAM_MESSAGE_METADATA, |
@@ -66,27 +66,55 @@ export const hideComment = ( |
66 | 66 | /** |
67 | 67 | * Sends a private DM reply to the author of a comment with an arbitrary |
68 | 68 | * 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. |
76 | 92 | * |
77 | 93 | * Stamps `message.metadata` like every other Instagram send path so the |
78 | 94 | * message_echo webhook (`handlers/webhook.ts`) recognizes and skips our own |
79 | 95 | * echo instead of re-ingesting it as an incoming message. |
80 | 96 | */ |
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 ( |
82 | 101 | auth: InstagramAuthValue, |
83 | 102 | commentId: string, |
84 | 103 | message: InstagramSendMessage | InstagramMessageAttachmentPayload, |
85 | 104 | ): Promise<InstagramSendMessageResponse> => { |
86 | 105 | 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` |
88 | 116 |
|
89 | | - return rescue(endpoint, () => |
| 117 | + return await rescue(endpoint, () => |
90 | 118 | instagramGraphClient.post<InstagramSendMessageResponse>(endpoint, { |
91 | 119 | headers: { |
92 | 120 | "Content-Type": "application/json", |
|
0 commit comments