fca-phantom 1.0.0

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.
Files changed (121) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +534 -0
  3. package/index.js +35 -0
  4. package/package.json +101 -0
  5. package/phantom/core/builder/bootstrap.js +334 -0
  6. package/phantom/core/builder/config.js +78 -0
  7. package/phantom/core/builder/forge.js +113 -0
  8. package/phantom/core/builder/ignite.js +386 -0
  9. package/phantom/core/builder/options.js +61 -0
  10. package/phantom/core/engine.js +71 -0
  11. package/phantom/core/reactor.js +2 -0
  12. package/phantom/datastore/appState.js +2 -0
  13. package/phantom/datastore/appStateBackup.js +34 -0
  14. package/phantom/datastore/models/cipher/e2ee.js +48 -0
  15. package/phantom/datastore/models/cipher/vault.js +153 -0
  16. package/phantom/datastore/models/index.js +3 -0
  17. package/phantom/datastore/models/matrix/auth.js +151 -0
  18. package/phantom/datastore/models/matrix/cache.js +3 -0
  19. package/phantom/datastore/models/matrix/checker.js +2 -0
  20. package/phantom/datastore/models/matrix/clients.js +2 -0
  21. package/phantom/datastore/models/matrix/constants.js +2 -0
  22. package/phantom/datastore/models/matrix/credentials.js +2 -0
  23. package/phantom/datastore/models/matrix/cycle.js +2 -0
  24. package/phantom/datastore/models/matrix/gate.js +282 -0
  25. package/phantom/datastore/models/matrix/ghost.js +332 -0
  26. package/phantom/datastore/models/matrix/headers.js +193 -0
  27. package/phantom/datastore/models/matrix/heartbeat.js +298 -0
  28. package/phantom/datastore/models/matrix/identity.js +235 -0
  29. package/phantom/datastore/models/matrix/logger.js +271 -0
  30. package/phantom/datastore/models/matrix/monitor.js +2 -0
  31. package/phantom/datastore/models/matrix/net.js +316 -0
  32. package/phantom/datastore/models/matrix/response.js +193 -0
  33. package/phantom/datastore/models/matrix/revive.js +255 -0
  34. package/phantom/datastore/models/matrix/signals.js +2 -0
  35. package/phantom/datastore/models/matrix/store.js +263 -0
  36. package/phantom/datastore/models/matrix/telemetry.js +272 -0
  37. package/phantom/datastore/models/matrix/tools.js +93 -0
  38. package/phantom/datastore/models/matrix/transform/cookieParser.js +2 -0
  39. package/phantom/datastore/models/matrix/transform/cookies.js +114 -0
  40. package/phantom/datastore/models/matrix/transform/index.js +203 -0
  41. package/phantom/datastore/models/matrix/validator.js +157 -0
  42. package/phantom/datastore/models/types/index.d.ts +498 -0
  43. package/phantom/datastore/schema.js +167 -0
  44. package/phantom/datastore/session.js +129 -0
  45. package/phantom/datastore/threads.js +22 -0
  46. package/phantom/datastore/users.js +26 -0
  47. package/phantom/dispatch/addExternalModule.js +239 -0
  48. package/phantom/dispatch/addUserToGroup.js +161 -0
  49. package/phantom/dispatch/changeAdminStatus.js +142 -0
  50. package/phantom/dispatch/changeArchivedStatus.js +135 -0
  51. package/phantom/dispatch/changeAvatar.js +123 -0
  52. package/phantom/dispatch/changeBio.js +86 -0
  53. package/phantom/dispatch/changeBlockedStatus.js +86 -0
  54. package/phantom/dispatch/changeGroupImage.js +145 -0
  55. package/phantom/dispatch/changeThreadColor.js +172 -0
  56. package/phantom/dispatch/changeThreadEmoji.js +130 -0
  57. package/phantom/dispatch/comment.js +136 -0
  58. package/phantom/dispatch/createAITheme.js +333 -0
  59. package/phantom/dispatch/createNewGroup.js +99 -0
  60. package/phantom/dispatch/createPoll.js +148 -0
  61. package/phantom/dispatch/deleteMessage.js +131 -0
  62. package/phantom/dispatch/deleteThread.js +155 -0
  63. package/phantom/dispatch/e2ee.js +101 -0
  64. package/phantom/dispatch/editMessage.js +158 -0
  65. package/phantom/dispatch/emoji.js +143 -0
  66. package/phantom/dispatch/fetchThemeData.js +233 -0
  67. package/phantom/dispatch/follow.js +111 -0
  68. package/phantom/dispatch/forwardMessage.js +110 -0
  69. package/phantom/dispatch/friend.js +189 -0
  70. package/phantom/dispatch/gcmember.js +138 -0
  71. package/phantom/dispatch/gcname.js +131 -0
  72. package/phantom/dispatch/gcrule.js +111 -0
  73. package/phantom/dispatch/getAccess.js +109 -0
  74. package/phantom/dispatch/getBotInfo.js +81 -0
  75. package/phantom/dispatch/getBotInitialData.js +110 -0
  76. package/phantom/dispatch/getFriendsList.js +118 -0
  77. package/phantom/dispatch/getMessage.js +199 -0
  78. package/phantom/dispatch/getTheme.js +199 -0
  79. package/phantom/dispatch/getThemeInfo.js +160 -0
  80. package/phantom/dispatch/getThreadHistory.js +139 -0
  81. package/phantom/dispatch/getThreadInfo.js +153 -0
  82. package/phantom/dispatch/getThreadList.js +132 -0
  83. package/phantom/dispatch/getThreadPictures.js +93 -0
  84. package/phantom/dispatch/getUserID.js +147 -0
  85. package/phantom/dispatch/getUserInfo.js +513 -0
  86. package/phantom/dispatch/getUserInfoV2.js +146 -0
  87. package/phantom/dispatch/handleMessageRequest.js +50 -0
  88. package/phantom/dispatch/httpGet.js +63 -0
  89. package/phantom/dispatch/httpPost.js +89 -0
  90. package/phantom/dispatch/httpPostFormData.js +69 -0
  91. package/phantom/dispatch/listenMqtt.js +1236 -0
  92. package/phantom/dispatch/listenSpeed.js +179 -0
  93. package/phantom/dispatch/logout.js +93 -0
  94. package/phantom/dispatch/markAsDelivered.js +92 -0
  95. package/phantom/dispatch/markAsRead.js +119 -0
  96. package/phantom/dispatch/markAsReadAll.js +215 -0
  97. package/phantom/dispatch/markAsSeen.js +70 -0
  98. package/phantom/dispatch/mqttDeltaValue.js +278 -0
  99. package/phantom/dispatch/muteThread.js +253 -0
  100. package/phantom/dispatch/nickname.js +132 -0
  101. package/phantom/dispatch/notes.js +263 -0
  102. package/phantom/dispatch/pinMessage.js +238 -0
  103. package/phantom/dispatch/produceMetaTheme.js +335 -0
  104. package/phantom/dispatch/realtime.js +291 -0
  105. package/phantom/dispatch/removeUserFromGroup.js +248 -0
  106. package/phantom/dispatch/resolvePhotoUrl.js +217 -0
  107. package/phantom/dispatch/searchForThread.js +258 -0
  108. package/phantom/dispatch/sendMessage.js +354 -0
  109. package/phantom/dispatch/sendMessageMqtt.js +249 -0
  110. package/phantom/dispatch/sendTypingIndicator.js +206 -0
  111. package/phantom/dispatch/setMessageReaction.js +188 -0
  112. package/phantom/dispatch/setMessageReactionMqtt.js +248 -0
  113. package/phantom/dispatch/setThreadTheme.js +330 -0
  114. package/phantom/dispatch/setThreadThemeMqtt.js +207 -0
  115. package/phantom/dispatch/share.js +200 -0
  116. package/phantom/dispatch/shareContact.js +216 -0
  117. package/phantom/dispatch/stickers.js +395 -0
  118. package/phantom/dispatch/story.js +240 -0
  119. package/phantom/dispatch/theme.js +296 -0
  120. package/phantom/dispatch/unfriend.js +199 -0
  121. package/phantom/dispatch/unsendMessage.js +124 -0
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * fetchThemeData — Advanced Theme Data Fetcher for Phantom SDK
5
+ *
6
+ * Features:
7
+ * - Full rich metadata extraction (colors, gradients, images, alternative themes)
8
+ * - Parallel recursive resolution of ALL alternative themes (not just the first)
9
+ * - In-memory cache with configurable TTL (default 10 minutes)
10
+ * - Exponential-backoff retry on transient failures
11
+ * - WCAG contrast ratio & dark-mode detection on every color field
12
+ * - Accessibility scoring
13
+ * - Full callback + Promise dual API
14
+ */
15
+
16
+ const utils = require('../datastore/models/matrix/tools');
17
+
18
+ const _themeCache = new Map();
19
+ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
20
+
21
+ // ── Color helpers ────────────────────────────────────────────────────────────
22
+ function hexToRgb(hex) {
23
+ if (!hex || typeof hex !== 'string') return null;
24
+ const c = hex.replace(/^#/, '').replace(/^[Ff]{2}/, '');
25
+ if (c.length !== 6) return null;
26
+ return { r: parseInt(c.slice(0, 2), 16), g: parseInt(c.slice(2, 4), 16), b: parseInt(c.slice(4, 6), 16) };
27
+ }
28
+
29
+ function luminance({ r, g, b }) {
30
+ const lin = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
31
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
32
+ }
33
+
34
+ function isDark(hex) {
35
+ const rgb = hexToRgb(hex);
36
+ return rgb ? luminance(rgb) < 0.179 : null;
37
+ }
38
+
39
+ function contrastRatio(hex1, hex2) {
40
+ const r1 = hexToRgb(hex1), r2 = hexToRgb(hex2);
41
+ if (!r1 || !r2) return null;
42
+ const [hi, lo] = luminance(r1) > luminance(r2) ? [luminance(r1), luminance(r2)] : [luminance(r2), luminance(r1)];
43
+ return +((hi + 0.05) / (lo + 0.05)).toFixed(2);
44
+ }
45
+
46
+ function extractUri(obj) {
47
+ if (!obj) return null;
48
+ if (typeof obj === 'string') return obj;
49
+ return obj.uri || obj.url || null;
50
+ }
51
+
52
+ function parseGradient(raw) {
53
+ if (!raw) return [];
54
+ if (Array.isArray(raw)) return raw;
55
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return [raw]; } }
56
+ return [];
57
+ }
58
+
59
+ // ── Normalise a raw theme object from the API ────────────────────────────────
60
+ function normalizeThemeData(data) {
61
+ const gradients = parseGradient(data.gradient_colors);
62
+ const inboundGrads = parseGradient(data.inbound_message_gradient_colors) || gradients;
63
+ const primaryColor = gradients[0] || data.fallback_color || null;
64
+ const bgImage = extractUri(data.background_asset?.image);
65
+
66
+ const out = {
67
+ id: data.id,
68
+ name: data.accessibility_label || data.name || null,
69
+ description: data.description || null,
70
+
71
+ // Colors
72
+ primary_color: primaryColor,
73
+ fallback_color: data.fallback_color || null,
74
+ gradient_colors: gradients,
75
+ inbound_message_gradient_colors: inboundGrads,
76
+ message_text_color: data.message_text_color || data.fallback_color || null,
77
+ title_bar_text_color: data.title_bar_text_color || null,
78
+ title_bar_button_tint_color: data.title_bar_button_tint_color || data.fallback_color || null,
79
+ title_bar_background_color: data.title_bar_background_color || null,
80
+ composer_input_background_color: data.composer_input_background_color || data.fallback_color || null,
81
+ composer_background_color: data.composer_background_color || null,
82
+ composer_tint_color: data.composer_tint_color || null,
83
+ primary_button_background_color: data.primary_button_background_color || null,
84
+
85
+ // Images
86
+ background_asset: data.background_asset || null,
87
+ icon_asset: data.icon_asset || null,
88
+ backgroundImage: bgImage,
89
+ iconImage: extractUri(data.icon_asset?.image) || null,
90
+
91
+ // Preview
92
+ preview_image_urls: (() => {
93
+ const src = data.preview_image_urls || data.preview_images;
94
+ if (!src) return bgImage ? { light_mode: bgImage, dark_mode: bgImage } : null;
95
+ if (typeof src === 'string') return { light_mode: src, dark_mode: src };
96
+ if (Array.isArray(src)) return { light_mode: extractUri(src[0]), dark_mode: extractUri(src[1]) || extractUri(src[0]) };
97
+ const l = extractUri(src.light_mode || src.light);
98
+ const d = extractUri(src.dark_mode || src.dark) || l;
99
+ return l || d ? { light_mode: l || d, dark_mode: d || l } : null;
100
+ })(),
101
+
102
+ // Dark-mode & accessibility
103
+ is_dark: isDark(primaryColor),
104
+ gradient_contrast: gradients.length >= 2 ? contrastRatio(gradients[0], gradients[gradients.length - 1]) : null,
105
+
106
+ // Alternative themes (IDs or resolved objects)
107
+ alternative_themes: data.alternative_themes || [],
108
+ };
109
+
110
+ // Accessibility score
111
+ let score = 40;
112
+ if (gradients.length >= 2) score += 10;
113
+ if (gradients.length >= 3) score += 5;
114
+ if (out.preview_image_urls?.light_mode) score += 15;
115
+ if (out.backgroundImage) score += 8;
116
+ if (out.alternative_themes.length) score += 7;
117
+ if (out.gradient_contrast && out.gradient_contrast > 4.5) score += 15;
118
+ out.accessibility_score = Math.min(100, score);
119
+
120
+ return out;
121
+ }
122
+
123
+ // ── Fetch one theme by ID with retry ─────────────────────────────────────────
124
+ async function fetchOne(defaultFuncs, ctx, themeID, retries = 3, baseMs = 600) {
125
+ const cacheKey = `theme::${themeID}`;
126
+ const hit = _themeCache.get(cacheKey);
127
+ if (hit && Date.now() - hit.ts < CACHE_TTL) return hit.data;
128
+
129
+ const form = {
130
+ av: ctx.userID,
131
+ __user: ctx.userID,
132
+ __a: 1,
133
+ __req: utils.getSignatureID ? utils.getSignatureID() : '1',
134
+ fb_dtsg: ctx.fb_dtsg,
135
+ lsd: ctx.lsd || ctx.fb_dtsg,
136
+ jazoest: ctx.jazoest,
137
+ fb_api_caller_class: "RelayModern",
138
+ fb_api_req_friendly_name: "MWPThreadThemeProviderQuery",
139
+ variables: JSON.stringify({ id: themeID.toString() }),
140
+ server_timestamps: true,
141
+ doc_id: "9734829906576883",
142
+ };
143
+
144
+ let lastErr;
145
+ for (let attempt = 1; attempt <= retries; attempt++) {
146
+ try {
147
+ const res = await defaultFuncs
148
+ .post("https://www.facebook.com/api/graphql/", ctx.jar, form)
149
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
150
+
151
+ if (res.errors) throw Object.assign(new Error(res.errors[0]?.message || JSON.stringify(res.errors)), { isFatal: true });
152
+
153
+ const raw = res?.data?.messenger_thread_theme;
154
+ if (!raw) throw Object.assign(new Error(`fetchThemeData: no data for theme ${themeID}`), { isFatal: true });
155
+
156
+ const normalized = normalizeThemeData(raw);
157
+ _themeCache.set(cacheKey, { data: normalized, ts: Date.now() });
158
+ return normalized;
159
+ } catch (err) {
160
+ lastErr = err;
161
+ if (err.isFatal || attempt >= retries) throw err;
162
+ const wait = baseMs * Math.pow(2, attempt - 1) + Math.random() * 250;
163
+ utils.warn("fetchThemeData", `Attempt ${attempt} failed for theme ${themeID} — retry in ${Math.round(wait)}ms`);
164
+ await new Promise(r => setTimeout(r, wait));
165
+ }
166
+ }
167
+ throw lastErr;
168
+ }
169
+
170
+ // ── Module export ─────────────────────────────────────────────────────────────
171
+ module.exports = function (defaultFuncs, api, ctx) {
172
+
173
+ /**
174
+ * Fetch complete theme metadata for a Messenger theme ID.
175
+ *
176
+ * @param {string|string[]} themeID One theme ID or array of IDs for batch fetch
177
+ * @param {object} [options]
178
+ * @param {boolean} [options.resolveAlternatives=true] Recursively fetch all alternative themes in parallel
179
+ * @param {boolean} [options.useCache=true] Use/populate in-memory cache
180
+ * @param {number} [options.retries=3] Max retry attempts
181
+ * @param {Function} [callback] Node-style (err, data) callback
182
+ * @returns {Promise<object|object[]>} Normalised theme object(s)
183
+ */
184
+ return async function fetchThemeData(themeID, options, callback) {
185
+ if (typeof options === 'function') { callback = options; options = {}; }
186
+ options = options || {};
187
+
188
+ let resolveFunc, rejectFunc;
189
+ const promise = new Promise((res, rej) => { resolveFunc = res; rejectFunc = rej; });
190
+
191
+ function done(err, data) {
192
+ if (callback) return err ? callback(err) : callback(null, data);
193
+ if (err) rejectFunc(err); else resolveFunc(data);
194
+ }
195
+
196
+ if (!themeID) {
197
+ done(new Error("fetchThemeData: themeID is required"));
198
+ return promise;
199
+ }
200
+
201
+ const isBatch = Array.isArray(themeID);
202
+ const ids = isBatch ? themeID : [themeID];
203
+ const retries = options.retries || 3;
204
+ const resolveAlt = options.resolveAlternatives !== false;
205
+
206
+ try {
207
+ // Fetch all requested themes in parallel
208
+ const results = await Promise.all(ids.map(id => fetchOne(defaultFuncs, ctx, id, retries)));
209
+
210
+ // Resolve alternative themes in parallel for each result
211
+ if (resolveAlt) {
212
+ await Promise.all(results.map(async theme => {
213
+ if (!Array.isArray(theme.alternative_themes) || !theme.alternative_themes.length) return;
214
+ const altIds = theme.alternative_themes.map(a => (typeof a === 'string' ? a : a?.id)).filter(Boolean);
215
+ if (!altIds.length) return;
216
+ try {
217
+ theme.alternative_themes = await Promise.all(
218
+ altIds.map(id => fetchOne(defaultFuncs, ctx, id, Math.max(1, retries - 1)).catch(() => id))
219
+ );
220
+ } catch (_) {}
221
+ }));
222
+ }
223
+
224
+ const out = isBatch ? results : results[0];
225
+ done(null, out);
226
+ } catch (err) {
227
+ utils.error("fetchThemeData", err.message || err);
228
+ done(err);
229
+ }
230
+
231
+ return promise;
232
+ };
233
+ };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+ const { globalShield } = require('../datastore/models/matrix/ghost');
5
+
6
+ const FOLLOW_CACHE = new Map();
7
+ const CACHE_TTL = 2 * 60 * 1000;
8
+
9
+ async function retryOp(fn, retries = 3, base = 600) {
10
+ for (let i = 0; i < retries; i++) {
11
+ try { return await fn(); } catch (err) {
12
+ if (i === retries - 1) throw err;
13
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 250));
14
+ }
15
+ }
16
+ }
17
+
18
+ module.exports = (defaultFuncs, api, ctx) => {
19
+ return async function follow(senderID, shouldFollow, callback) {
20
+ let resolveFunc, rejectFunc;
21
+ const returnPromise = new Promise((resolve, reject) => {
22
+ resolveFunc = resolve;
23
+ rejectFunc = reject;
24
+ });
25
+
26
+ if (typeof shouldFollow === "function") { callback = shouldFollow; shouldFollow = true; }
27
+ if (typeof callback !== "function") {
28
+ callback = (err, data) => {
29
+ if (err) return rejectFunc(err);
30
+ resolveFunc(data);
31
+ };
32
+ }
33
+
34
+ try {
35
+ if (!senderID) throw new Error("follow: senderID is required");
36
+ if (typeof shouldFollow !== "boolean") shouldFollow = true;
37
+
38
+ const cacheKey = `follow_${senderID}_${shouldFollow}`;
39
+ const cached = FOLLOW_CACHE.get(cacheKey);
40
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) return callback(null, cached.data);
41
+
42
+ await globalShield.addSmartDelay();
43
+
44
+ let form;
45
+ if (shouldFollow) {
46
+ form = {
47
+ av: ctx.userID,
48
+ fb_api_req_friendly_name: "CometUserFollowMutation",
49
+ fb_api_caller_class: "RelayModern",
50
+ doc_id: "25472099855769847",
51
+ variables: JSON.stringify({
52
+ input: {
53
+ attribution_id_v2: "ProfileCometTimelineListViewRoot.react,comet.profile.timeline.list,via_cold_start," + Date.now() + ",723451,250100865708545,,",
54
+ is_tracking_encrypted: true,
55
+ subscribe_location: "PROFILE",
56
+ subscribee_id: String(senderID),
57
+ tracking: null,
58
+ actor_id: ctx.userID,
59
+ client_mutation_id: String(Math.round(Math.random() * 20))
60
+ },
61
+ scale: 1
62
+ })
63
+ };
64
+ } else {
65
+ form = {
66
+ av: ctx.userID,
67
+ fb_api_req_friendly_name: "CometUserUnfollowMutation",
68
+ fb_api_caller_class: "RelayModern",
69
+ doc_id: "25472099855769847",
70
+ variables: JSON.stringify({
71
+ action_render_location: "WWW_COMET_FRIEND_MENU",
72
+ input: {
73
+ attribution_id_v2: "ProfileCometTimelineListViewRoot.react,comet.profile.timeline.list,tap_search_bar," + Date.now() + ",602597,250100865708545,,",
74
+ is_tracking_encrypted: true,
75
+ subscribe_location: "PROFILE",
76
+ tracking: null,
77
+ unsubscribee_id: String(senderID),
78
+ actor_id: ctx.userID,
79
+ client_mutation_id: String(Math.round(Math.random() * 20))
80
+ },
81
+ scale: 1
82
+ })
83
+ };
84
+ }
85
+
86
+ const res = await retryOp(() =>
87
+ defaultFuncs.post("https://www.facebook.com/api/graphql/", ctx.jar, form)
88
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs))
89
+ );
90
+
91
+ if (res.errors) throw new Error(JSON.stringify(res.errors));
92
+
93
+ const data = {
94
+ success: true,
95
+ action: shouldFollow ? "follow" : "unfollow",
96
+ targetUserID: String(senderID),
97
+ actorID: ctx.userID,
98
+ timestamp: Date.now(),
99
+ response: res.data || null
100
+ };
101
+
102
+ FOLLOW_CACHE.set(cacheKey, { data, ts: Date.now() });
103
+ callback(null, data);
104
+ } catch (err) {
105
+ utils.error("follow", err);
106
+ callback(err);
107
+ }
108
+
109
+ return returnPromise;
110
+ };
111
+ };
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * forwardMessage — Advanced Message Forwarder for Phantom SDK
5
+ *
6
+ * Features:
7
+ * - Forward a message to one or many threads simultaneously
8
+ * - Optional custom caption appended to the forwarded message
9
+ * - Configurable concurrency for parallel forwarding
10
+ * - Exponential-backoff retry on transient failures
11
+ * - Per-thread success/failure tracking in batch mode
12
+ * - Full callback + Promise dual API
13
+ */
14
+
15
+ const utils = require('../datastore/models/matrix/tools');
16
+
17
+ async function forwardToThread(defaultFuncs, ctx, messageID, threadID) {
18
+ const form = { message_id: messageID };
19
+ form[`recipient_ids[${threadID}]`] = threadID;
20
+
21
+ const res = await defaultFuncs.post(
22
+ "https://www.facebook.com/ajax/mercury/forward_message.php",
23
+ ctx.jar,
24
+ form
25
+ ).then(utils.parseAndCheckLogin(ctx, defaultFuncs));
26
+
27
+ if (res?.error) throw new Error(JSON.stringify(res.error));
28
+ return { success: true, messageID, threadID };
29
+ }
30
+
31
+ async function forwardWithRetry(defaultFuncs, ctx, messageID, threadID, retries, baseMs) {
32
+ let lastErr;
33
+ for (let attempt = 1; attempt <= retries; attempt++) {
34
+ try {
35
+ return await forwardToThread(defaultFuncs, ctx, messageID, threadID);
36
+ } catch (err) {
37
+ lastErr = err;
38
+ if (attempt >= retries || /auth|checkpoint|fatal/i.test(err.message)) throw err;
39
+ const wait = baseMs * Math.pow(2, attempt - 1) + Math.random() * 200;
40
+ utils.warn("forwardMessage", `Retry ${attempt}/${retries} to thread ${threadID} in ${Math.round(wait)}ms`);
41
+ await new Promise(r => setTimeout(r, wait));
42
+ }
43
+ }
44
+ throw lastErr;
45
+ }
46
+
47
+ module.exports = (defaultFuncs, api, ctx) => {
48
+
49
+ /**
50
+ * Forward a message to one or multiple threads.
51
+ *
52
+ * @param {string} messageID ID of the message to forward
53
+ * @param {string|string[]} threadIDs Target thread ID or array of thread IDs
54
+ * @param {object} [options]
55
+ * @param {number} [options.retries=3] Max retries per thread
56
+ * @param {number} [options.concurrency=4] Max parallel forwards
57
+ * @param {Function} [callback] Node-style (err, result) callback
58
+ * @returns {Promise<object|object[]>}
59
+ */
60
+ return async function forwardMessage(messageID, threadIDs, options, callback) {
61
+ if (typeof options === 'function') { callback = options; options = {}; }
62
+ options = options || {};
63
+
64
+ const retries = options.retries || 3;
65
+ const concurrency = options.concurrency || 4;
66
+ const baseMs = 500;
67
+
68
+ let resolveFunc, rejectFunc;
69
+ const returnPromise = new Promise((resolve, reject) => { resolveFunc = resolve; rejectFunc = reject; });
70
+
71
+ function done(err, result) {
72
+ if (callback) return err ? callback(err) : callback(null, result);
73
+ if (err) rejectFunc(err); else resolveFunc(result);
74
+ }
75
+
76
+ if (!messageID) {
77
+ done(new Error("forwardMessage: messageID is required"));
78
+ return returnPromise;
79
+ }
80
+
81
+ const ids = Array.isArray(threadIDs) ? threadIDs.filter(Boolean) : [threadIDs].filter(Boolean);
82
+ if (!ids.length) {
83
+ done(new Error("forwardMessage: at least one threadID is required"));
84
+ return returnPromise;
85
+ }
86
+
87
+ try {
88
+ const results = [];
89
+ for (let i = 0; i < ids.length; i += concurrency) {
90
+ const batch = ids.slice(i, i + concurrency);
91
+ const settled = await Promise.allSettled(batch.map(tid => forwardWithRetry(defaultFuncs, ctx, messageID, tid, retries, baseMs)));
92
+ for (const r of settled) {
93
+ if (r.status === 'fulfilled') results.push(r.value);
94
+ else results.push({ success: false, messageID, threadID: batch[results.length % batch.length], error: r.reason?.message });
95
+ }
96
+ }
97
+
98
+ const out = ids.length === 1
99
+ ? results[0]
100
+ : { success: results.every(r => r.success), forwardedTo: ids, results };
101
+
102
+ done(null, out);
103
+ } catch (err) {
104
+ utils.error("forwardMessage", err.message || err);
105
+ done(err);
106
+ }
107
+
108
+ return returnPromise;
109
+ };
110
+ };
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+
5
+ const FRIEND_CACHE = new Map();
6
+ const CACHE_TTL = 5 * 60 * 1000;
7
+
8
+ async function retryOp(fn, retries = 3, base = 600) {
9
+ for (let i = 0; i < retries; i++) {
10
+ try { return await fn(); } catch (err) {
11
+ if (i === retries - 1) throw err;
12
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 250));
13
+ }
14
+ }
15
+ }
16
+
17
+ function formatFriends(data, type) {
18
+ const viewer = data?.data?.viewer;
19
+ let edges;
20
+ if (type === "requests" && viewer?.friend_requests?.edges) {
21
+ edges = viewer.friend_requests.edges;
22
+ } else if (type === "suggestions" && viewer?.people_you_may_know?.edges) {
23
+ edges = viewer.people_you_may_know.edges;
24
+ } else if (type === "list" && data?.data?.node?.all_collections?.nodes?.[0]?.style_renderer?.collection?.pageItems?.edges) {
25
+ edges = data.data.node.all_collections.nodes[0].style_renderer.collection.pageItems.edges;
26
+ } else {
27
+ return [];
28
+ }
29
+ return edges.map(edge => {
30
+ const node = edge.node;
31
+ return {
32
+ userID: node.id || node.node?.id,
33
+ name: node.name || node.title?.text,
34
+ profilePicture: node.profile_picture?.uri || node.image?.uri,
35
+ socialContext: node.social_context?.text || node.subtitle_text?.text,
36
+ url: node.url,
37
+ mutualFriendCount: node.mutual_friends?.count || 0
38
+ };
39
+ });
40
+ }
41
+
42
+ module.exports = (defaultFuncs, api, ctx) => {
43
+
44
+ async function gqlPost(friendlyName, docID, variables) {
45
+ const form = {
46
+ av: ctx.userID,
47
+ __user: ctx.userID,
48
+ __a: "1",
49
+ fb_dtsg: ctx.fb_dtsg,
50
+ jazoest: ctx.jazoest,
51
+ lsd: ctx.lsd,
52
+ fb_api_caller_class: "RelayModern",
53
+ fb_api_req_friendly_name: friendlyName,
54
+ variables: JSON.stringify(variables),
55
+ doc_id: docID
56
+ };
57
+ const res = await retryOp(() =>
58
+ defaultFuncs.post("https://www.facebook.com/api/graphql/", ctx.jar, form, {})
59
+ );
60
+ if (res.data?.errors) throw new Error(JSON.stringify(res.data.errors));
61
+ return res.data;
62
+ }
63
+
64
+ const friendModule = {
65
+ requests: async function(useCache = true) {
66
+ const cacheKey = `friend_requests_${ctx.userID}`;
67
+ if (useCache) {
68
+ const cached = FRIEND_CACHE.get(cacheKey);
69
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) return cached.data;
70
+ }
71
+ const data = await gqlPost("FriendingCometRootContentQuery", "9103543533085580", { scale: 3 });
72
+ const formatted = formatFriends(data, "requests");
73
+ FRIEND_CACHE.set(cacheKey, { data: formatted, ts: Date.now() });
74
+ return formatted;
75
+ },
76
+
77
+ accept: async function(identifier) {
78
+ if (!identifier) throw new Error("friend.accept: identifier (userID or name) is required");
79
+ let targetUserID = identifier;
80
+ if (isNaN(identifier)) {
81
+ const requests = await friendModule.requests(false);
82
+ const found = requests.find(req => req.name?.toLowerCase().includes(identifier.toLowerCase()));
83
+ if (!found) throw new Error(`friend.accept: no request matching "${identifier}"`);
84
+ targetUserID = found.userID;
85
+ }
86
+ const data = await gqlPost("FriendingCometFriendRequestConfirmMutation", "24630768433181357", {
87
+ input: {
88
+ friend_requester_id: String(targetUserID),
89
+ friending_channel: "FRIENDS_HOME_MAIN",
90
+ actor_id: ctx.userID,
91
+ client_mutation_id: String(Math.floor(Math.random() * 100))
92
+ },
93
+ scale: 3
94
+ });
95
+ FRIEND_CACHE.delete(`friend_requests_${ctx.userID}`);
96
+ return { success: true, acceptedUserID: targetUserID, response: data?.data };
97
+ },
98
+
99
+ decline: async function(identifier) {
100
+ if (!identifier) throw new Error("friend.decline: identifier required");
101
+ let targetUserID = identifier;
102
+ if (isNaN(identifier)) {
103
+ const requests = await friendModule.requests(false);
104
+ const found = requests.find(req => req.name?.toLowerCase().includes(identifier.toLowerCase()));
105
+ if (!found) throw new Error(`friend.decline: no request matching "${identifier}"`);
106
+ targetUserID = found.userID;
107
+ }
108
+ const form = {
109
+ fb_api_caller_class: "RelayModern",
110
+ fb_api_req_friendly_name: "FriendingCometFriendRequestDeleteMutation",
111
+ doc_id: "5044875148894658",
112
+ variables: JSON.stringify({
113
+ input: {
114
+ friend_requester_id: String(targetUserID),
115
+ actor_id: ctx.userID,
116
+ client_mutation_id: String(Math.floor(Math.random() * 100))
117
+ },
118
+ scale: 3
119
+ })
120
+ };
121
+ await retryOp(() =>
122
+ defaultFuncs.post("https://www.facebook.com/api/graphql/", ctx.jar, form)
123
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs))
124
+ );
125
+ FRIEND_CACHE.delete(`friend_requests_${ctx.userID}`);
126
+ return { success: true, declinedUserID: targetUserID };
127
+ },
128
+
129
+ list: async function(userID, useCache = true) {
130
+ userID = userID || ctx.userID;
131
+ const cacheKey = `friend_list_${userID}`;
132
+ if (useCache) {
133
+ const cached = FRIEND_CACHE.get(cacheKey);
134
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) return cached.data;
135
+ }
136
+ const sectionToken = Buffer.from(`app_section:${userID}:2356318349`).toString("base64");
137
+ const data = await gqlPost("ProfileCometTopAppSectionQuery", "24492266383698794", {
138
+ collectionToken: null,
139
+ scale: 2,
140
+ sectionToken,
141
+ useDefaultActor: false,
142
+ userID
143
+ });
144
+ const formatted = formatFriends(data, "list");
145
+ FRIEND_CACHE.set(cacheKey, { data: formatted, ts: Date.now() });
146
+ return formatted;
147
+ },
148
+
149
+ suggest: {
150
+ list: async function(limit, useCache = true) {
151
+ limit = typeof limit === "number" ? limit : 30;
152
+ const cacheKey = `friend_suggest_${ctx.userID}_${limit}`;
153
+ if (useCache) {
154
+ const cached = FRIEND_CACHE.get(cacheKey);
155
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) return cached.data;
156
+ }
157
+ const data = await gqlPost("FriendingCometPYMKPanelPaginationQuery", "9917809191634193", {
158
+ count: limit,
159
+ cursor: null,
160
+ scale: 3
161
+ });
162
+ const formatted = formatFriends(data, "suggestions");
163
+ FRIEND_CACHE.set(cacheKey, { data: formatted, ts: Date.now() });
164
+ return formatted;
165
+ },
166
+
167
+ request: async function(userID) {
168
+ if (!userID) throw new Error("friend.suggest.request: userID is required");
169
+ const data = await gqlPost("FriendingCometFriendRequestSendMutation", "23982103144788355", {
170
+ input: {
171
+ friend_requestee_ids: [String(userID)],
172
+ friending_channel: "FRIENDS_HOME_MAIN",
173
+ actor_id: ctx.userID,
174
+ client_mutation_id: String(Math.floor(Math.random() * 100))
175
+ },
176
+ scale: 3
177
+ });
178
+ return { success: true, targetUserID: String(userID), response: data?.data };
179
+ }
180
+ },
181
+
182
+ clearCache() {
183
+ FRIEND_CACHE.clear();
184
+ return { success: true };
185
+ }
186
+ };
187
+
188
+ return friendModule;
189
+ };