bb-fca 2.0.17 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ import utils = require('../../../utils');
2
+
3
+ /**
4
+ * Extracts the numeric post ID from a Facebook post URL by fetching the page HTML.
5
+ * Supports formats: full URL, /posts/pfbid..., /posts/numericID, permalink.php, etc.
6
+ * @param {object} defaultFuncs - The default functions for making API requests.
7
+ * @param {object} ctx - The context object.
8
+ * @param {string} postUrl - The Facebook post URL.
9
+ * @returns {Promise<string>} The numeric post ID.
10
+ */
11
+ async function resolvePostID(
12
+ defaultFuncs,
13
+ ctx,
14
+ postUrl: string,
15
+ ): Promise<string> {
16
+ // Build fetch URL
17
+ let fetchUrl: string;
18
+ if (postUrl.startsWith('http://') || postUrl.startsWith('https://')) {
19
+ fetchUrl = postUrl;
20
+ } else if (postUrl.startsWith('/')) {
21
+ fetchUrl = `https://www.facebook.com${postUrl}`;
22
+ } else {
23
+ fetchUrl = `https://www.facebook.com/${postUrl}`;
24
+ }
25
+
26
+ const resData = await utils.get(
27
+ fetchUrl,
28
+ ctx.jar,
29
+ {},
30
+ ctx.globalOptions,
31
+ ctx,
32
+ );
33
+ const html = resData.body.toString();
34
+
35
+ // Try to find post_id from the HTML JSON data
36
+ const scriptMatches = html.match(
37
+ /<script type="application\/json"\s+data-content-len="\d+"\s+data-sjs>(.*?)<\/script>/gs,
38
+ );
39
+
40
+ if (!scriptMatches) {
41
+ throw new Error('Could not parse post HTML to find post ID.');
42
+ }
43
+
44
+ let postID: string | null = null;
45
+
46
+ // Deep search helper
47
+ function deepFind(obj: any, key: string): string | null {
48
+ if (!obj || typeof obj !== 'object') return null;
49
+ for (const k of Object.keys(obj)) {
50
+ if (k === key && typeof obj[k] === 'string' && obj[k].length > 0) {
51
+ return obj[k];
52
+ }
53
+ const found = deepFind(obj[k], key);
54
+ if (found) return found;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ for (const scriptMatch of scriptMatches) {
60
+ try {
61
+ const jsonMatch = scriptMatch.match(/<script[^>]*>(.*?)<\/script>/s);
62
+ if (!jsonMatch) continue;
63
+ const jsonData = JSON.parse(jsonMatch[1]);
64
+ const found = deepFind(jsonData, 'post_id');
65
+ if (found) {
66
+ postID = found;
67
+ break;
68
+ }
69
+ } catch (e) {
70
+ continue;
71
+ }
72
+ }
73
+
74
+ if (!postID) {
75
+ // Fallback: try to extract from URL patterns
76
+ // e.g. /posts/3228319350779313:415318807385347
77
+ const urlMatch = postUrl.match(/posts\/(\d+)/);
78
+ if (urlMatch) {
79
+ postID = urlMatch[1];
80
+ }
81
+ }
82
+
83
+ if (!postID) {
84
+ // Fallback: try story_fbid from permalink.php
85
+ const storyMatch = postUrl.match(/story_fbid=(\d+)/);
86
+ if (storyMatch) {
87
+ postID = storyMatch[1];
88
+ }
89
+ }
90
+
91
+ if (!postID) {
92
+ throw new Error('Could not resolve post ID from the provided URL.');
93
+ }
94
+
95
+ return postID;
96
+ }
97
+
98
+ /**
99
+ * Submits the reply comment to the GraphQL endpoint.
100
+ * @param {object} defaultFuncs - The default functions.
101
+ * @param {object} ctx - The context object.
102
+ * @param {object} variables - The fully constructed variables object.
103
+ * @returns {Promise<object>} A promise that resolves with the comment info.
104
+ */
105
+ async function submitReply(defaultFuncs, ctx, variables) {
106
+ const res = await defaultFuncs
107
+ .post('https://www.facebook.com/api/graphql/', ctx.jar, {
108
+ fb_api_caller_class: 'RelayModern',
109
+ fb_api_req_friendly_name: 'useCometUFICreateCommentMutation',
110
+ variables: JSON.stringify(variables),
111
+ server_timestamps: true,
112
+ doc_id: '26613344231661138',
113
+ })
114
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
115
+
116
+ if (res.errors) {
117
+ throw res;
118
+ }
119
+
120
+ const commentEdge = res.data?.comment_create?.feedback_comment_edge;
121
+ if (!commentEdge) {
122
+ throw new Error(
123
+ 'Unexpected response structure: missing comment_create.feedback_comment_edge',
124
+ );
125
+ }
126
+
127
+ return {
128
+ id: commentEdge.node.id,
129
+ url: commentEdge.node.feedback?.url || null,
130
+ count: res.data.comment_create.feedback?.total_comment_count || 0,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Creates a reply to a comment on a Facebook post.
136
+ *
137
+ * @param {string} postUrl - The URL of the Facebook post (e.g. https://www.facebook.com/user/posts/123456789).
138
+ * @param {string} commentID - The numeric ID of the comment to reply to.
139
+ * @param {string} text - The reply text content.
140
+ * @param {function} [callback] - Optional callback function (err, result).
141
+ * @returns {Promise<object>} A promise that resolves with the new reply's information:
142
+ * - id: The GraphQL ID of the new reply.
143
+ * - url: The URL of the reply (if available).
144
+ * - count: The total comment count on the post.
145
+ *
146
+ * @example
147
+ * // Using async/await
148
+ * const result = await api.replyComment(
149
+ * 'https://www.facebook.com/user/posts/3228319350779313',
150
+ * '3873188569648912',
151
+ * 'Cảm ơn bạn!'
152
+ * );
153
+ * console.log(result.id);
154
+ *
155
+ * // Using callback
156
+ * api.replyComment(url, commentId, 'Hello!', (err, result) => {
157
+ * if (err) console.error(err);
158
+ * else console.log(result);
159
+ * });
160
+ */
161
+ export default function(defaultFuncs: any, api: any, ctx: any) {
162
+ return async function replyComment(
163
+ postUrl: string,
164
+ commentID: string,
165
+ text: string,
166
+ callback?: Function,
167
+ ) {
168
+ let cb: (error: any, info?: any) => void;
169
+ const returnPromise = new Promise<any>((resolve, reject) => {
170
+ cb = (error, info) => (info ? resolve(info) : reject(error));
171
+ });
172
+
173
+ if (typeof callback === 'function') {
174
+ cb = callback as any;
175
+ }
176
+
177
+ // ── Validate inputs ──────────────────────────────────────────────
178
+ if (!postUrl || typeof postUrl !== 'string') {
179
+ const error = 'postUrl must be a non-empty string (Facebook post URL).';
180
+ utils.error('replyComment', error);
181
+ return cb(error);
182
+ }
183
+ if (!commentID || typeof commentID !== 'string') {
184
+ const error =
185
+ 'commentID must be a non-empty string (numeric comment ID).';
186
+ utils.error('replyComment', error);
187
+ return cb(error);
188
+ }
189
+ if (!text || typeof text !== 'string') {
190
+ const error = 'text must be a non-empty string (reply content).';
191
+ utils.error('replyComment', error);
192
+ return cb(error);
193
+ }
194
+
195
+ try {
196
+ // ── Step 1: Resolve the numeric post ID from the URL ─────────
197
+ const postID = await resolvePostID(defaultFuncs, ctx, postUrl);
198
+ utils.log('replyComment', `Resolved postID: ${postID}`);
199
+
200
+ // ── Step 2: Build the correct base64 IDs ─────────────────────
201
+ // feedback_id = base64("feedback:{postID}_{commentID}")
202
+ const feedbackIdRaw = `feedback:${postID}_${commentID}`;
203
+ const feedbackIdBase64 = Buffer.from(feedbackIdRaw).toString('base64');
204
+
205
+ // reply_comment_parent_fbid = base64("comment:{postID}_{commentID}")
206
+ const replyParentRaw = `comment:${postID}_${commentID}`;
207
+ const replyParentBase64 = Buffer.from(replyParentRaw).toString('base64');
208
+
209
+ // ── Step 3: Build the variables payload ──────────────────────
210
+ const variables = {
211
+ feedLocation: 'POST_PERMALINK_DIALOG',
212
+ feedbackSource: 2,
213
+ groupID: null,
214
+ input: {
215
+ actor_id: ctx.userID,
216
+ client_mutation_id: Math.round(Math.random() * 19).toString(),
217
+ attachments: null,
218
+ feedback_id: feedbackIdBase64,
219
+ formatting_style: null,
220
+ message: {
221
+ ranges: [],
222
+ text: text,
223
+ },
224
+ reply_comment_parent_fbid: replyParentBase64,
225
+ reply_target_clicked: true,
226
+ attribution_id_v2: `CometSinglePostDialogRoot.react,comet.post.single_dialog,via_cold_start,${Date.now()},846664,,,`,
227
+ vod_video_timestamp: null,
228
+ is_tracking_encrypted: true,
229
+ tracking: [],
230
+ feedback_source: 'OBJECT',
231
+ idempotence_token: 'client:' + utils.getGUID(),
232
+ session_id: utils.getGUID(),
233
+ downstream_share_session_id: utils.getGUID(),
234
+ downstream_share_session_origin_uri: postUrl,
235
+ downstream_share_session_start_time: Date.now().toString(),
236
+ },
237
+ inviteShortLinkKey: null,
238
+ renderLocation: null,
239
+ scale: 1,
240
+ useDefaultActor: false,
241
+ focusCommentID: null,
242
+ __relay_internal__pv__groups_comet_use_glvrelayprovider: false,
243
+ __relay_internal__pv__CometUFICommentActionLinksRewriteEnabledrelayprovider: false,
244
+ __relay_internal__pv__CometUFICommentAvatarStickerAnimatedImagerelayprovider: false,
245
+ __relay_internal__pv__IsWorkUserrelayprovider: false,
246
+ __relay_internal__pv__CometUFICommentAutoTranslationTyperelayprovider:
247
+ 'ORIGINAL',
248
+ };
249
+
250
+ // ── Step 4: Submit the reply ──────────────────────────────────
251
+ const info = await submitReply(defaultFuncs, ctx, variables);
252
+ utils.log('replyComment', `Reply created successfully: ${info.id}`);
253
+ cb(null, info);
254
+ } catch (err) {
255
+ utils.error('replyComment', err);
256
+ cb(err);
257
+ }
258
+
259
+ return returnPromise;
260
+ };
261
+ }
@@ -705,6 +705,63 @@ export interface API {
705
705
  callback?: Callback<CommentResult>,
706
706
  ): Promise<CommentResult>;
707
707
 
708
+ /**
709
+ * Reply to a comment on a Facebook post.
710
+ * @param postUrl - The URL of the Facebook post.
711
+ * @param commentID - The numeric ID of the comment to reply to.
712
+ * @param text - The reply text content.
713
+ * @param callback - Optional callback function.
714
+ * @returns A promise that resolves with the new reply's information.
715
+ */
716
+ replyComment(
717
+ postUrl: string,
718
+ commentID: string,
719
+ text: string,
720
+ callback?: Callback<CommentResult>,
721
+ ): Promise<CommentResult>;
722
+
723
+ /**
724
+ * Upload and publish a video as a Facebook Reel.
725
+ * Full flow: upload video → copyright check → publish.
726
+ *
727
+ * @param options - The reel options.
728
+ * @param options.videoPath - File path to the video file.
729
+ * @param options.message - Caption text for the reel.
730
+ * @param options.privacy - Privacy: "EVERYONE", "FRIENDS", or "SELF". Default: "EVERYONE".
731
+ * @param options.skipCopyrightCheck - Skip copyright check polling. Default: false.
732
+ * @param options.copyrightCheckTimeout - Max time for copyright check (ms). Default: 60000.
733
+ * @param options.copyrightCheckInterval - Polling interval for copyright check (ms). Default: 2000.
734
+ * @param callback - Optional callback function.
735
+ * @returns A promise that resolves with the reel's information.
736
+ */
737
+ createReel(
738
+ options: {
739
+ videoPath: string;
740
+ message?: string;
741
+ privacy?: 'EVERYONE' | 'FRIENDS' | 'SELF';
742
+ skipCopyrightCheck?: boolean;
743
+ copyrightCheckTimeout?: number;
744
+ copyrightCheckInterval?: number;
745
+ },
746
+ callback?: Callback<{
747
+ success: boolean;
748
+ postID: string | null;
749
+ storyID: string | null;
750
+ videoID: string;
751
+ publishingFlow: string | null;
752
+ composerSessionId: string;
753
+ data: any;
754
+ }>,
755
+ ): Promise<{
756
+ success: boolean;
757
+ postID: string | null;
758
+ storyID: string | null;
759
+ videoID: string;
760
+ publishingFlow: string | null;
761
+ composerSessionId: string;
762
+ data: any;
763
+ }>;
764
+
708
765
  /** Share a Facebook post. */
709
766
  share(
710
767
  text: string,
@@ -19,8 +19,19 @@ let proxyAgent: any = null;
19
19
  // Request interceptor: attach cookies from jar + proxy agent
20
20
  (client.interceptors as any).request.use((config: any) => {
21
21
  const url = config.url || '';
22
+ const currentJar = config.jar || jar;
22
23
  try {
23
- const cookieString = jar.getCookieStringSync(url);
24
+ let cookieString = currentJar.getCookieStringSync(url);
25
+
26
+ // Cross-domain fallback: if URL is a facebook.com subdomain, ensure we send facebook.com cookies
27
+ if (url.includes('facebook.com') || url.includes('messenger.com')) {
28
+ const baseCookies = currentJar.getCookieStringSync('https://www.facebook.com');
29
+ if (baseCookies) {
30
+ // Merge cookies if necessary, but usually baseCookies is sufficient for subdomains
31
+ cookieString = cookieString ? `${cookieString}; ${baseCookies}` : baseCookies;
32
+ }
33
+ }
34
+
24
35
  if (cookieString) {
25
36
  config.headers = config.headers || {};
26
37
  config.headers['Cookie'] = cookieString;
@@ -41,11 +52,15 @@ let proxyAgent: any = null;
41
52
  // Response interceptor: save set-cookie headers back to jar
42
53
  (client.interceptors as any).response.use((response: any) => {
43
54
  const url = response.config.url || '';
55
+ const currentJar = response.config.jar || jar;
44
56
  const setCookieHeaders = response.headers['set-cookie'];
45
57
  if (setCookieHeaders && Array.isArray(setCookieHeaders)) {
46
58
  for (const cookie of setCookieHeaders) {
47
59
  try {
48
- jar.setCookieSync(cookie, url);
60
+ currentJar.setCookieSync(cookie, url);
61
+ if (url.includes('facebook.com')) {
62
+ currentJar.setCookieSync(cookie, 'https://www.facebook.com');
63
+ }
49
64
  } catch (e) {
50
65
  // Ignore invalid cookies
51
66
  }
@@ -56,11 +71,15 @@ let proxyAgent: any = null;
56
71
  // Also save cookies from error responses
57
72
  if (error.response) {
58
73
  const url = error.response.config?.url || '';
74
+ const currentJar = error.response.config?.jar || jar;
59
75
  const setCookieHeaders = error.response.headers?.['set-cookie'];
60
76
  if (setCookieHeaders && Array.isArray(setCookieHeaders)) {
61
77
  for (const cookie of setCookieHeaders) {
62
78
  try {
63
- jar.setCookieSync(cookie, url);
79
+ currentJar.setCookieSync(cookie, url);
80
+ if (url.includes('facebook.com')) {
81
+ currentJar.setCookieSync(cookie, 'https://www.facebook.com');
82
+ }
64
83
  } catch (e) {
65
84
  // Ignore
66
85
  }
@@ -148,6 +167,7 @@ export async function get(
148
167
  timeout: 60000,
149
168
  params: qs,
150
169
  validateStatus: (status: number) => status >= 200 && status < 600,
170
+ jar: reqJar,
151
171
  };
152
172
  return requestWithRetry(async () => await client.get(url, config));
153
173
  }
@@ -186,6 +206,7 @@ export async function post(
186
206
  headers,
187
207
  timeout: 60000,
188
208
  validateStatus: (status: number) => status >= 200 && status < 600,
209
+ jar: reqJar,
189
210
  };
190
211
  return requestWithRetry(async () => await client.post(url, data, config));
191
212
  }
@@ -214,6 +235,7 @@ export async function postFormData(
214
235
  timeout: 60000,
215
236
  params: qs,
216
237
  validateStatus: (status: number) => status >= 200 && status < 600,
238
+ jar: reqJar,
217
239
  };
218
240
  return requestWithRetry(async () => await client.post(url, formData, config));
219
241
  }
@@ -232,6 +254,7 @@ export async function postRaw(
232
254
  maxContentLength: Infinity,
233
255
  maxBodyLength: Infinity,
234
256
  validateStatus: (status: number) => status >= 200 && status < 600,
257
+ jar: reqJar,
235
258
  };
236
259
  return requestWithRetry(async () => await client.post(url, body, config));
237
260
  }