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,248 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+ const { globalShield } = require('../datastore/models/matrix/ghost');
5
+
6
+ const REMOVE_HISTORY = [];
7
+ const MAX_HISTORY = 200;
8
+ const UNDO_STACK = [];
9
+ const MAX_UNDO = 50;
10
+ const INFLIGHT = new Map();
11
+
12
+ async function retryOp(fn, retries = 4, base = 500) {
13
+ for (let i = 0; i < retries; i++) {
14
+ try { return await fn(); } catch (err) {
15
+ if (i === retries - 1) throw err;
16
+ const transient = /network|timeout|ECONNRESET|ETIMEDOUT|5\d\d|429/i.test(String(err?.message || err));
17
+ if (!transient) throw err;
18
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 300));
19
+ }
20
+ }
21
+ }
22
+
23
+ async function mqttRemoveUser(ctx, userID, threadID) {
24
+ if (!ctx.mqttClient) throw new Error('MQTT not connected');
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const reqID = ++ctx.wsReqNumber;
28
+ const taskID = ++ctx.wsTaskNumber;
29
+
30
+ const payload = JSON.stringify({
31
+ epoch_id: utils.generateOfflineThreadingID(),
32
+ tasks: [{
33
+ failure_count: null,
34
+ label: '140',
35
+ payload: JSON.stringify({
36
+ thread_id: String(threadID),
37
+ contact_id: String(userID),
38
+ sync_group: 1
39
+ }),
40
+ queue_name: 'remove_participant_v2',
41
+ task_id: taskID
42
+ }],
43
+ version_id: '8798795233522156'
44
+ });
45
+
46
+ const form = JSON.stringify({
47
+ app_id: '2220391788200892',
48
+ payload,
49
+ request_id: reqID,
50
+ type: 3
51
+ });
52
+
53
+ let handled = false;
54
+ const onResp = (topic, message) => {
55
+ if (topic !== '/ls_resp' || handled) return;
56
+ let j;
57
+ try { j = JSON.parse(message.toString()); j.payload = JSON.parse(j.payload); } catch { return; }
58
+ if (j.request_id !== reqID) return;
59
+ handled = true;
60
+ clearTimeout(timer);
61
+ ctx.mqttClient.removeListener('message', onResp);
62
+ resolve({ success: true, method: 'mqtt', userID: String(userID), threadID: String(threadID) });
63
+ };
64
+
65
+ const timer = setTimeout(() => {
66
+ if (!handled) {
67
+ handled = true;
68
+ ctx.mqttClient.removeListener('message', onResp);
69
+ reject(new Error('MQTT timeout for removeUserFromGroup'));
70
+ }
71
+ }, 30000);
72
+
73
+ ctx.mqttClient.on('message', onResp);
74
+ ctx.mqttClient.publish('/ls_req', form, { qos: 1, retain: false }, (err) => {
75
+ if (err && !handled) {
76
+ handled = true;
77
+ clearTimeout(timer);
78
+ ctx.mqttClient.removeListener('message', onResp);
79
+ reject(err);
80
+ }
81
+ });
82
+ });
83
+ }
84
+
85
+ async function httpRemoveUser(defaultFuncs, ctx, userID, threadID) {
86
+ const form = { uid: String(userID), tid: String(threadID) };
87
+ const res = await defaultFuncs.post(
88
+ 'https://www.facebook.com/chat/remove_participants',
89
+ ctx.jar,
90
+ form
91
+ ).then(utils.parseAndCheckLogin(ctx, defaultFuncs));
92
+
93
+ if (res && res.error) throw res;
94
+ return { success: true, method: 'http', userID: String(userID), threadID: String(threadID) };
95
+ }
96
+
97
+ async function graphqlRemoveUser(defaultFuncs, ctx, userID, threadID) {
98
+ const form = {
99
+ fb_api_caller_class: 'RelayModern',
100
+ fb_api_req_friendly_name: 'MessengerRemoveParticipantMutation',
101
+ variables: JSON.stringify({
102
+ input: {
103
+ thread_fbid: String(threadID),
104
+ participant_id: String(userID),
105
+ actor_id: ctx.userID,
106
+ client_mutation_id: String(Math.round(Math.random() * 10000))
107
+ }
108
+ }),
109
+ doc_id: '5476918952395415'
110
+ };
111
+
112
+ const res = await defaultFuncs.post('https://www.facebook.com/api/graphql/', ctx.jar, form)
113
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
114
+ if (res?.errors?.length) throw new Error(JSON.stringify(res.errors));
115
+ return { success: true, method: 'graphql', userID: String(userID), threadID: String(threadID) };
116
+ }
117
+
118
+ function recordHistory(op) {
119
+ REMOVE_HISTORY.unshift({ ...op, ts: Date.now() });
120
+ if (REMOVE_HISTORY.length > MAX_HISTORY) REMOVE_HISTORY.length = MAX_HISTORY;
121
+ }
122
+
123
+ module.exports = (defaultFuncs, api, ctx) => {
124
+
125
+ const removeUserFromGroup = async function removeUserFromGroup(userOrUsers, threadID, options, callback) {
126
+ if (typeof options === 'function') { callback = options; options = {}; }
127
+ if (!options || typeof options !== 'object') options = {};
128
+ const {
129
+ preferMqtt = true,
130
+ batchDelay = 400,
131
+ dryRun = false
132
+ } = options;
133
+
134
+ let resolveFunc, rejectFunc;
135
+ const returnPromise = new Promise((resolve, reject) => {
136
+ resolveFunc = resolve;
137
+ rejectFunc = reject;
138
+ });
139
+
140
+ if (typeof callback !== 'function') {
141
+ callback = (err, data) => {
142
+ if (err) return rejectFunc(err);
143
+ resolveFunc(data);
144
+ };
145
+ }
146
+
147
+ try {
148
+ if (!threadID) throw new Error('removeUserFromGroup: threadID is required');
149
+ const tType = utils.getType(threadID);
150
+ if (tType !== 'Number' && tType !== 'String') throw new Error('removeUserFromGroup: threadID must be Number or String');
151
+
152
+ const userIDs = Array.isArray(userOrUsers) ? userOrUsers.map(String) : [String(userOrUsers)];
153
+
154
+ for (const uid of userIDs) {
155
+ const uType = utils.getType(uid);
156
+ if (uType !== 'Number' && uType !== 'String') throw new Error(`removeUserFromGroup: userID "${uid}" must be Number or String`);
157
+ }
158
+
159
+ if (dryRun) {
160
+ return callback(null, { dryRun: true, wouldRemove: userIDs, threadID: String(threadID), timestamp: Date.now() });
161
+ }
162
+
163
+ await globalShield.addSmartDelay();
164
+
165
+ const results = [];
166
+ const errors = [];
167
+
168
+ for (let i = 0; i < userIDs.length; i++) {
169
+ const uid = userIDs[i];
170
+ if (i > 0) await new Promise(r => setTimeout(r, batchDelay + Math.random() * 200));
171
+
172
+ const inflightKey = `${uid}_${threadID}`;
173
+ if (INFLIGHT.has(inflightKey)) {
174
+ try {
175
+ const r = await INFLIGHT.get(inflightKey);
176
+ results.push({ ...r, fromInFlight: true });
177
+ } catch (err) {
178
+ errors.push({ userID: uid, error: err.message });
179
+ }
180
+ continue;
181
+ }
182
+
183
+ const promise = (async () => {
184
+ let result;
185
+
186
+ if (preferMqtt && ctx.mqttClient) {
187
+ try {
188
+ result = await retryOp(() => mqttRemoveUser(ctx, uid, threadID));
189
+ } catch (mqttErr) {
190
+ utils.warn('removeUserFromGroup', `MQTT failed for ${uid}, trying HTTP:`, mqttErr.message);
191
+ try {
192
+ result = await retryOp(() => httpRemoveUser(defaultFuncs, ctx, uid, threadID));
193
+ } catch (httpErr) {
194
+ utils.warn('removeUserFromGroup', `HTTP failed, trying GraphQL:`, httpErr.message);
195
+ result = await retryOp(() => graphqlRemoveUser(defaultFuncs, ctx, uid, threadID));
196
+ }
197
+ }
198
+ } else {
199
+ try {
200
+ result = await retryOp(() => httpRemoveUser(defaultFuncs, ctx, uid, threadID));
201
+ } catch (httpErr) {
202
+ utils.warn('removeUserFromGroup', `HTTP failed, trying GraphQL:`, httpErr.message);
203
+ result = await retryOp(() => graphqlRemoveUser(defaultFuncs, ctx, uid, threadID));
204
+ }
205
+ }
206
+
207
+ return result;
208
+ })();
209
+
210
+ INFLIGHT.set(inflightKey, promise);
211
+
212
+ try {
213
+ const result = await promise;
214
+ results.push({ ...result, timestamp: Date.now() });
215
+ recordHistory({ userID: uid, threadID: String(threadID), method: result.method });
216
+ UNDO_STACK.unshift({ userID: uid, threadID: String(threadID), action: 'removed', ts: Date.now() });
217
+ if (UNDO_STACK.length > MAX_UNDO) UNDO_STACK.length = MAX_UNDO;
218
+ utils.log('removeUserFromGroup', `Removed user ${uid} from thread ${threadID} via ${result.method}`);
219
+ } catch (err) {
220
+ errors.push({ userID: uid, error: err.message || String(err) });
221
+ } finally {
222
+ INFLIGHT.delete(inflightKey);
223
+ }
224
+ }
225
+
226
+ const isSingle = userIDs.length === 1;
227
+ const finalResult = isSingle && results.length === 1
228
+ ? results[0]
229
+ : { success: errors.length === 0, results, errors: errors.length ? errors : undefined, threadID: String(threadID), timestamp: Date.now() };
230
+
231
+ if (errors.length === userIDs.length) {
232
+ return callback(new Error(`removeUserFromGroup: all ${userIDs.length} remove(s) failed — ${errors[0]?.error}`));
233
+ }
234
+
235
+ callback(null, finalResult);
236
+ } catch (err) {
237
+ utils.error('removeUserFromGroup', err);
238
+ callback(err);
239
+ }
240
+
241
+ return returnPromise;
242
+ };
243
+
244
+ removeUserFromGroup.getHistory = (limit = 30) => REMOVE_HISTORY.slice(0, limit);
245
+ removeUserFromGroup.getUndoStack = () => [...UNDO_STACK];
246
+
247
+ return removeUserFromGroup;
248
+ };
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+ const { globalShield } = require('../datastore/models/matrix/ghost');
5
+
6
+ const PHOTO_CACHE = new Map();
7
+ const PHOTO_CACHE_TTL = 10 * 60 * 1000;
8
+ const PHOTO_HISTORY = [];
9
+ const MAX_HISTORY = 300;
10
+ const INFLIGHT = new Map();
11
+
12
+ async function retryOp(fn, retries = 4, base = 500) {
13
+ for (let i = 0; i < retries; i++) {
14
+ try { return await fn(); } catch (err) {
15
+ if (i === retries - 1) throw err;
16
+ const transient = /network|timeout|ECONNRESET|ETIMEDOUT|5\d\d|429/i.test(String(err?.message || err));
17
+ if (!transient) throw err;
18
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 200));
19
+ }
20
+ }
21
+ }
22
+
23
+ async function fetchPhotoUrlHttp(defaultFuncs, ctx, photoID) {
24
+ const resData = await defaultFuncs.get(
25
+ 'https://www.facebook.com/mercury/attachments/photo',
26
+ ctx.jar,
27
+ { photo_id: String(photoID) }
28
+ ).then(utils.parseAndCheckLogin(ctx, defaultFuncs));
29
+
30
+ if (resData.error) throw resData;
31
+
32
+ const photoUrl = resData.jsmods?.require?.[0]?.[3]?.[0];
33
+ if (!photoUrl) throw new Error(`resolvePhotoUrl: URL not found in response for photo ${photoID}`);
34
+ return { photoUrl, method: 'http', photoID: String(photoID), timestamp: Date.now() };
35
+ }
36
+
37
+ async function fetchPhotoUrlGraphQL(defaultFuncs, ctx, photoID) {
38
+ const form = {
39
+ fb_api_caller_class: 'RelayModern',
40
+ fb_api_req_friendly_name: 'CometPhotoRootContentQuery',
41
+ variables: JSON.stringify({ photoid: String(photoID), transparentBackground: false, UFIContainerType: 'default' }),
42
+ doc_id: '9553764061351979'
43
+ };
44
+ const res = await defaultFuncs.post('https://www.facebook.com/api/graphql/', ctx.jar, form)
45
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
46
+
47
+ if (res?.errors?.length) throw new Error(JSON.stringify(res.errors));
48
+
49
+ const media = res?.data?.viewer?.media;
50
+ const url = media?.image?.uri || media?.photo_image?.uri || media?.url;
51
+ if (!url) throw new Error(`resolvePhotoUrl: GraphQL URL not found for photo ${photoID}`);
52
+ return { photoUrl: url, method: 'graphql', photoID: String(photoID), timestamp: Date.now() };
53
+ }
54
+
55
+ function recordHistory(op) {
56
+ PHOTO_HISTORY.unshift({ ...op, ts: Date.now() });
57
+ if (PHOTO_HISTORY.length > MAX_HISTORY) PHOTO_HISTORY.length = MAX_HISTORY;
58
+ }
59
+
60
+ function checkCache(photoID) {
61
+ const key = `photo_${photoID}`;
62
+ const cached = PHOTO_CACHE.get(key);
63
+ if (cached && Date.now() - cached.ts < PHOTO_CACHE_TTL) return cached.result;
64
+ return null;
65
+ }
66
+
67
+ function setCache(photoID, result) {
68
+ PHOTO_CACHE.set(`photo_${photoID}`, { result, ts: Date.now() });
69
+ }
70
+
71
+ module.exports = function (defaultFuncs, api, ctx) {
72
+
73
+ const resolvePhotoUrl = async function resolvePhotoUrl(photoOrPhotos, options, callback) {
74
+ if (typeof options === 'function') { callback = options; options = {}; }
75
+ if (!options || typeof options !== 'object') options = {};
76
+ const {
77
+ skipCache = false,
78
+ preferGraphQL = false,
79
+ fallback = true,
80
+ metadataOnly = false
81
+ } = options;
82
+
83
+ let resolveFunc, rejectFunc;
84
+ const returnPromise = new Promise((resolve, reject) => {
85
+ resolveFunc = resolve;
86
+ rejectFunc = reject;
87
+ });
88
+
89
+ if (typeof callback !== 'function') {
90
+ callback = (err, data) => {
91
+ if (err) return rejectFunc(err);
92
+ resolveFunc(data);
93
+ };
94
+ }
95
+
96
+ try {
97
+ const isBatch = Array.isArray(photoOrPhotos);
98
+ const photoIDs = isBatch ? photoOrPhotos.map(String) : [String(photoOrPhotos)];
99
+
100
+ if (photoIDs.length === 0) throw new Error('resolvePhotoUrl: at least one photoID is required');
101
+
102
+ const results = [];
103
+ const errors = [];
104
+
105
+ for (const photoID of photoIDs) {
106
+ if (!photoID || photoID === 'undefined' || photoID === 'null') {
107
+ errors.push({ photoID, error: 'invalid photoID' });
108
+ continue;
109
+ }
110
+
111
+ if (!skipCache) {
112
+ const cached = checkCache(photoID);
113
+ if (cached) {
114
+ results.push({ ...cached, fromCache: true });
115
+ continue;
116
+ }
117
+ }
118
+
119
+ if (INFLIGHT.has(photoID)) {
120
+ try {
121
+ const result = await INFLIGHT.get(photoID);
122
+ results.push({ ...result, fromInFlight: true });
123
+ } catch (err) {
124
+ errors.push({ photoID, error: err.message || String(err) });
125
+ }
126
+ continue;
127
+ }
128
+
129
+ const inflightPromise = (async () => {
130
+ let result;
131
+ if (preferGraphQL) {
132
+ try {
133
+ result = await retryOp(() => fetchPhotoUrlGraphQL(defaultFuncs, ctx, photoID));
134
+ } catch (gqlErr) {
135
+ if (!fallback) throw gqlErr;
136
+ utils.warn('resolvePhotoUrl', `GraphQL failed for ${photoID}, using HTTP:`, gqlErr.message);
137
+ result = await retryOp(() => fetchPhotoUrlHttp(defaultFuncs, ctx, photoID));
138
+ }
139
+ } else {
140
+ try {
141
+ result = await retryOp(() => fetchPhotoUrlHttp(defaultFuncs, ctx, photoID));
142
+ } catch (httpErr) {
143
+ if (!fallback) throw httpErr;
144
+ utils.warn('resolvePhotoUrl', `HTTP failed for ${photoID}, using GraphQL:`, httpErr.message);
145
+ result = await retryOp(() => fetchPhotoUrlGraphQL(defaultFuncs, ctx, photoID));
146
+ }
147
+ }
148
+ return result;
149
+ })();
150
+
151
+ INFLIGHT.set(photoID, inflightPromise);
152
+
153
+ try {
154
+ const result = await inflightPromise;
155
+ setCache(photoID, result);
156
+ recordHistory({ photoID, method: result.method, url: result.photoUrl });
157
+ results.push(metadataOnly ? { photoID, method: result.method, timestamp: result.timestamp } : result);
158
+ } catch (err) {
159
+ errors.push({ photoID, error: err.message || String(err) });
160
+ } finally {
161
+ INFLIGHT.delete(photoID);
162
+ }
163
+ }
164
+
165
+ if (!isBatch && results.length === 1) {
166
+ const r = results[0];
167
+ return callback(null, r.photoUrl || r);
168
+ }
169
+
170
+ if (!isBatch && results.length === 0 && errors.length > 0) {
171
+ return callback(errors[0].error instanceof Error ? errors[0].error : new Error(errors[0].error));
172
+ }
173
+
174
+ const finalResult = {
175
+ success: errors.length === 0,
176
+ results,
177
+ errors: errors.length ? errors : undefined,
178
+ resolvedCount: results.length,
179
+ timestamp: Date.now()
180
+ };
181
+
182
+ callback(null, finalResult);
183
+ } catch (err) {
184
+ utils.error('resolvePhotoUrl', err);
185
+ callback(err);
186
+ }
187
+
188
+ return returnPromise;
189
+ };
190
+
191
+ resolvePhotoUrl.batch = async function batchResolve(photoIDs, options = {}) {
192
+ return resolvePhotoUrl(photoIDs, options);
193
+ };
194
+
195
+ resolvePhotoUrl.getHistory = (limit = 50) => PHOTO_HISTORY.slice(0, limit);
196
+
197
+ resolvePhotoUrl.clearCache = (photoID) => {
198
+ if (photoID) {
199
+ PHOTO_CACHE.delete(`photo_${photoID}`);
200
+ } else {
201
+ PHOTO_CACHE.clear();
202
+ }
203
+ return { success: true };
204
+ };
205
+
206
+ resolvePhotoUrl.getCacheStats = () => {
207
+ const now = Date.now();
208
+ let valid = 0, expired = 0;
209
+ for (const [, entry] of PHOTO_CACHE) {
210
+ if (now - entry.ts < PHOTO_CACHE_TTL) valid++;
211
+ else expired++;
212
+ }
213
+ return { total: PHOTO_CACHE.size, valid, expired };
214
+ };
215
+
216
+ return resolvePhotoUrl;
217
+ };