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,118 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+
5
+ const FRIENDS_CACHE = new Map();
6
+ const CACHE_TTL = 5 * 60 * 1000;
7
+
8
+ const GENDERS = {
9
+ 0: "unknown", 1: "female_singular", 2: "male_singular",
10
+ 3: "female_singular_guess", 4: "male_singular_guess",
11
+ 5: "mixed", 6: "neuter_singular", 7: "unknown_singular",
12
+ 8: "female_plural", 9: "male_plural", 10: "neuter_plural",
13
+ 11: "unknown_plural"
14
+ };
15
+
16
+ function formatData(obj) {
17
+ return Object.keys(obj).map(key => {
18
+ const user = obj[key];
19
+ return {
20
+ alternateName: user.alternateName || null,
21
+ firstName: user.firstName || null,
22
+ gender: GENDERS[user.gender] || "unknown",
23
+ userID: utils.formatID(String(user.id)),
24
+ isFriend: user.is_friend != null ? !!user.is_friend : false,
25
+ fullName: user.name || null,
26
+ profilePicture: user.thumbSrc || null,
27
+ type: user.type || null,
28
+ profileUrl: user.uri || null,
29
+ vanity: user.vanity || null,
30
+ isBirthday: !!user.is_birthday
31
+ };
32
+ });
33
+ }
34
+
35
+ async function retryOp(fn, retries = 3, base = 700) {
36
+ for (let i = 0; i < retries; i++) {
37
+ try { return await fn(); } catch (err) {
38
+ if (i === retries - 1) throw err;
39
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 300));
40
+ }
41
+ }
42
+ }
43
+
44
+ async function fetchViaGraphQL(defaultFuncs, ctx) {
45
+ const form = {
46
+ fb_api_caller_class: "RelayModern",
47
+ fb_api_req_friendly_name: "FriendingCometFriendListQuery",
48
+ doc_id: "28046706820346836",
49
+ variables: JSON.stringify({ count: 1000, cursor: null, scale: 2 })
50
+ };
51
+ const res = await defaultFuncs.post("https://www.facebook.com/api/graphql/", ctx.jar, form)
52
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
53
+ if (res.errors) throw new Error(JSON.stringify(res.errors));
54
+ const edges = res.data?.viewer?.all_friends?.edges || [];
55
+ return edges.map(edge => ({
56
+ userID: edge.node?.id,
57
+ fullName: edge.node?.name,
58
+ profilePicture: edge.node?.profile_picture?.uri,
59
+ profileUrl: edge.node?.url,
60
+ alternateName: null,
61
+ firstName: edge.node?.short_name || null,
62
+ gender: "unknown",
63
+ isFriend: true,
64
+ type: "friend",
65
+ vanity: null,
66
+ isBirthday: false
67
+ }));
68
+ }
69
+
70
+ module.exports = (defaultFuncs, api, ctx) => {
71
+ return async function getFriendsList(callback) {
72
+ let resolveFunc, rejectFunc;
73
+ const returnPromise = new Promise((resolve, reject) => {
74
+ resolveFunc = resolve;
75
+ rejectFunc = reject;
76
+ });
77
+
78
+ if (typeof callback !== "function") {
79
+ callback = (err, result) => {
80
+ if (err) return rejectFunc(err);
81
+ resolveFunc(result);
82
+ };
83
+ }
84
+
85
+ try {
86
+ const cacheKey = `friendslist_${ctx.userID}`;
87
+ const cached = FRIENDS_CACHE.get(cacheKey);
88
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) {
89
+ return callback(null, cached.data);
90
+ }
91
+
92
+ let result;
93
+
94
+ try {
95
+ const res = await retryOp(() =>
96
+ defaultFuncs.postFormData(
97
+ "https://www.facebook.com/chat/user_info_all",
98
+ ctx.jar, {},
99
+ { viewer: ctx.userID }
100
+ ).then(utils.parseAndCheckLogin(ctx, defaultFuncs))
101
+ );
102
+ if (!res || res.error) throw res || new Error("Empty response");
103
+ result = formatData(res.payload);
104
+ } catch (legacyErr) {
105
+ utils.warn("getFriendsList", "Legacy API failed, trying GraphQL:", legacyErr.message || legacyErr.error);
106
+ result = await retryOp(() => fetchViaGraphQL(defaultFuncs, ctx));
107
+ }
108
+
109
+ FRIENDS_CACHE.set(cacheKey, { data: result, ts: Date.now() });
110
+ callback(null, result);
111
+ } catch (err) {
112
+ utils.error("getFriendsList", err);
113
+ callback(err);
114
+ }
115
+
116
+ return returnPromise;
117
+ };
118
+ };
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+
3
+ const utils = require('../datastore/models/matrix/tools');
4
+ const { formatAttachment: _formatAttachment } = require('../datastore/models/matrix/transform/index');
5
+
6
+ const MSG_CACHE = new Map();
7
+ const CACHE_TTL = 3 * 60 * 1000;
8
+
9
+ const THEME_COLORS = [
10
+ { theme_color: "FF000000", theme_id: "788274591712841", theme_name_with_subtitle: "Monochrome" },
11
+ { theme_color: "FF0084FF", theme_id: "196241301102133", theme_name_with_subtitle: "Default Blue" },
12
+ { theme_color: "FFFF5CA1", theme_id: "169463077092846", theme_name_with_subtitle: "Hot Pink" },
13
+ { theme_color: "FF2825B5", theme_id: "271607034185782", theme_name_with_subtitle: "Shadow" },
14
+ { theme_color: "FF7646FF", theme_id: "234137870477637", theme_name_with_subtitle: "Bright Purple" },
15
+ { theme_color: "FF44BEC7", theme_id: "1928399724138152", theme_name_with_subtitle: "Teal Blue" },
16
+ { theme_color: "FFFE5F03", theme_id: "175615189761153", theme_name_with_subtitle: "Orange" },
17
+ { theme_color: "FF13CF13", theme_id: "2136751179887052", theme_name_with_subtitle: "Green" },
18
+ { theme_color: "FFFA3C4C", theme_id: "2129984390566328", theme_name_with_subtitle: "Red" },
19
+ { theme_color: "FFF25C54", theme_id: "3022526817824329", theme_name_with_subtitle: "Peach" },
20
+ { theme_color: "FFF01D6A", theme_id: "724096885023603", theme_name_with_subtitle: "Berry" },
21
+ { theme_color: "FFFF7CA8", theme_id: "624266884847972", theme_name_with_subtitle: "Candy" }
22
+ ];
23
+
24
+ async function retryOp(fn, retries = 3, base = 600) {
25
+ for (let i = 0; i < retries; i++) {
26
+ try { return await fn(); } catch (err) {
27
+ if (i === retries - 1) throw err;
28
+ await new Promise(r => setTimeout(r, base * Math.pow(2, i) + Math.random() * 250));
29
+ }
30
+ }
31
+ }
32
+
33
+ function formatMessage(threadID, data) {
34
+ const baseMessage = {
35
+ threadID,
36
+ messageID: data.message_id,
37
+ timestamp: data.timestamp_precise,
38
+ author: data.message_sender ? data.message_sender.id : null
39
+ };
40
+
41
+ switch (data.__typename) {
42
+ case "ThreadNameMessage":
43
+ return { ...baseMessage, type: "event", logMessageType: "log:thread-name", logMessageData: { name: data.thread_name }, logMessageBody: data.snippet };
44
+
45
+ case "ThreadImageMessage":
46
+ const meta = data.image_with_metadata;
47
+ return { ...baseMessage, type: "event", logMessageType: "log:thread-image", logMessageData: meta ? { attachmentID: meta.legacy_attachment_id, width: meta.original_dimensions?.x, height: meta.original_dimensions?.y, url: meta.preview?.uri } : null, logMessageBody: data.snippet };
48
+
49
+ case "GenericAdminTextMessage": {
50
+ const adminType = data.extensible_message_admin_text_type;
51
+ const adminText = data.extensible_message_admin_text;
52
+
53
+ const adminTypeMap = {
54
+ CHANGE_THREAD_THEME: () => {
55
+ const colorMatch = THEME_COLORS.find(c => c.theme_color === adminText?.theme_color);
56
+ return { logMessageType: "log:thread-color", logMessageData: colorMatch || { theme_color: adminText?.theme_color } };
57
+ },
58
+ CHANGE_THREAD_ICON: () => {
59
+ const icon = adminText?.thread_icon;
60
+ let iconUrl = null;
61
+ if (icon) {
62
+ try { iconUrl = `https://static.xx.fbcdn.net/images/emoji.php/v9/t3c/1/16/${icon.codePointAt(0).toString(16)}.png`; } catch {}
63
+ }
64
+ return { logMessageType: "log:thread-icon", logMessageData: { thread_icon: icon || null, thread_icon_url: iconUrl } };
65
+ },
66
+ CHANGE_THREAD_NICKNAME: () => ({ logMessageType: "log:user-nickname", logMessageData: { nickname: adminText?.nickname, participant_id: adminText?.participant_id } }),
67
+ GROUP_POLL: () => {
68
+ const q = adminText?.question;
69
+ if (!q) return { logMessageType: "log:thread-poll", logMessageData: { error: "Missing poll question data" } };
70
+ return { logMessageType: "log:thread-poll", logMessageData: { question_json: JSON.stringify({ id: q.id, text: q.text, options: (q.options?.nodes || []).map(o => ({ id: o.id, text: o.text, voters: (o.voters?.nodes || []).map(v => v.id) })) }), event_type: (adminText.event_type || "").toLowerCase(), question_id: q.id } };
71
+ },
72
+ CHANGE_THREAD_QUICK_REACTION: () => ({ logMessageType: "log:thread-icon", logMessageData: { thread_quick_reaction: adminText?.thread_quick_reaction } }),
73
+ CHANGE_THREAD_ADMINS: () => ({ logMessageType: "log:thread-admins", logMessageData: { admin_type: adminText?.admin_type, target_id: adminText?.target_id } }),
74
+ CHANGE_THREAD_APPROVAL_MODE: () => ({ logMessageType: "log:thread-approval-mode", logMessageData: { approval_mode: adminText?.approval_mode } }),
75
+ PIN_MESSAGES_V2: () => ({ logMessageType: "log:thread-pinned", logMessageData: { pinned_message_id: adminText?.pinned_message_id } }),
76
+ UNPIN_MESSAGES_V2: () => ({ logMessageType: "log:unpin-message", logMessageData: { unpinned_message_id: adminText?.unpinned_message_id } }),
77
+ JOINABLE_GROUP_LINK_MODE_CHANGE: () => ({ logMessageType: "log:link-status", logMessageData: { link_status: adminText?.joinable_mode } }),
78
+ MAGIC_WORDS: () => ({ logMessageType: "log:magic-words", logMessageData: { magic_word: adminText?.magic_word } }),
79
+ MESSENGER_CALL_LOG: () => ({ logMessageType: "log:thread-call", logMessageData: { event_type: adminType, call_duration: adminText?.call_duration || 0 } }),
80
+ PARTICIPANT_JOINED_GROUP_CALL: () => ({ logMessageType: "log:thread-call", logMessageData: { event_type: adminType, call_duration: adminText?.call_duration || 0 } })
81
+ };
82
+
83
+ const handler = adminTypeMap[adminType];
84
+ const extra = handler ? handler() : { logMessageType: "log:generic-admin", logMessageData: { admin_type: adminType } };
85
+ return { ...baseMessage, type: "event", ...extra, logMessageBody: data.snippet };
86
+ }
87
+
88
+ case "UserMessage": {
89
+ const attachments = [];
90
+ if (data.blob_attachments?.length) {
91
+ for (const att of data.blob_attachments) {
92
+ try { attachments.push(_formatAttachment(att)); } catch (ex) { attachments.push({ type: "unknown", error: ex.message, rawAttachment: att }); }
93
+ }
94
+ } else if (data.extensible_attachment && Object.keys(data.extensible_attachment).length) {
95
+ try {
96
+ attachments.push(_formatAttachment({ extensible_attachment: data.extensible_attachment }));
97
+ } catch (ex) {
98
+ const sa = data.extensible_attachment.story_attachment || {};
99
+ attachments.push({ type: "share", ID: data.extensible_attachment.legacy_attachment_id, url: sa.url, title: sa.title_with_entities?.text, description: sa.description?.text, source: sa.source?.text, image: sa.media?.image?.uri, playable: sa.media?.is_playable || false });
100
+ }
101
+ }
102
+
103
+ const mentions = {};
104
+ if (data.message?.ranges) {
105
+ for (const mention of data.message.ranges) {
106
+ if (mention.entity?.id && data.message.text) {
107
+ mentions[mention.entity.id] = data.message.text.substring(mention.offset, mention.offset + mention.length);
108
+ }
109
+ }
110
+ }
111
+
112
+ return {
113
+ type: "message",
114
+ senderID: data.message_sender?.id || null,
115
+ body: data.message?.text || "",
116
+ threadID,
117
+ messageID: data.message_id,
118
+ reactions: (data.message_reactions || []).map(r => ({ [r.user.id]: r.reaction })),
119
+ attachments,
120
+ mentions,
121
+ timestamp: data.timestamp_precise,
122
+ isUnsent: !!data.is_unsent
123
+ };
124
+ }
125
+
126
+ default:
127
+ return { ...baseMessage, type: "unknown", data };
128
+ }
129
+ }
130
+
131
+ function parseDelta(threadID, delta) {
132
+ if (delta.replied_to_message) {
133
+ return { type: "message_reply", ...formatMessage(threadID, delta), messageReply: formatMessage(threadID, delta.replied_to_message.message) };
134
+ }
135
+ return formatMessage(threadID, delta);
136
+ }
137
+
138
+ module.exports = (defaultFuncs, api, ctx) => {
139
+ return function getMessage(threadID, messageID, callback) {
140
+ let resolveFunc, rejectFunc;
141
+ const returnPromise = new Promise((resolve, reject) => {
142
+ resolveFunc = resolve;
143
+ rejectFunc = reject;
144
+ });
145
+
146
+ if (typeof callback !== "function") {
147
+ callback = (err, info) => {
148
+ if (err) return rejectFunc(err);
149
+ resolveFunc(info);
150
+ };
151
+ }
152
+
153
+ if (!threadID || !messageID) {
154
+ callback({ error: "getMessage: threadID and messageID are required" });
155
+ return returnPromise;
156
+ }
157
+
158
+ const cacheKey = `msg_${threadID}_${messageID}`;
159
+ const cached = MSG_CACHE.get(cacheKey);
160
+ if (cached && (Date.now() - cached.ts < CACHE_TTL)) {
161
+ callback(null, cached.data);
162
+ return returnPromise;
163
+ }
164
+
165
+ const form = {
166
+ av: ctx.userID,
167
+ fb_dtsg: ctx.fb_dtsg,
168
+ queries: JSON.stringify({
169
+ o0: {
170
+ doc_id: "1768656253222505",
171
+ query_params: {
172
+ thread_and_message_id: { thread_id: String(threadID), message_id: String(messageID) }
173
+ }
174
+ }
175
+ })
176
+ };
177
+
178
+ retryOp(() =>
179
+ defaultFuncs.post("https://www.facebook.com/api/graphqlbatch/", ctx.jar, form)
180
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs))
181
+ ).then(resData => {
182
+ if (!resData || !resData.length) throw { error: "getMessage: no response data" };
183
+ if (resData[resData.length - 1].error_results > 0) throw resData[0].o0.errors;
184
+ if (resData[resData.length - 1].successful_results === 0) throw { error: "getMessage: no successful results" };
185
+
186
+ const fetchData = resData[0].o0.data.message;
187
+ if (!fetchData) throw { error: "getMessage: message not found" };
188
+
189
+ const result = parseDelta(String(threadID), fetchData);
190
+ MSG_CACHE.set(cacheKey, { data: result, ts: Date.now() });
191
+ callback(null, result);
192
+ }).catch(err => {
193
+ utils.error("getMessage", err);
194
+ callback(err);
195
+ });
196
+
197
+ return returnPromise;
198
+ };
199
+ };
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * getTheme — Advanced Thread Theme Catalogue Fetcher for Phantom SDK
5
+ *
6
+ * Features:
7
+ * - Fetches full catalogue of available Messenger themes for a thread
8
+ * - Parallel detailed metadata resolution for all themes via fetchThemeData
9
+ * - In-memory catalogue cache (8-minute TTL)
10
+ * - Rich normalisation: gradients, images, dark-mode flag, accessibility score
11
+ * - Search helpers: findByName(), findByColor(), findByID()
12
+ * - Full callback + Promise dual API
13
+ */
14
+
15
+ const utils = require('../datastore/models/matrix/tools');
16
+
17
+ const _catalogueCache = new Map();
18
+ const CACHE_TTL = 8 * 60 * 1000; // 8 minutes
19
+
20
+ function parseGradient(raw) {
21
+ if (!raw) return [];
22
+ if (Array.isArray(raw)) return raw;
23
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return [raw]; } }
24
+ return [];
25
+ }
26
+
27
+ function hexToRgb(hex) {
28
+ if (!hex) return null;
29
+ const c = hex.replace(/^#/, '').replace(/^[Ff]{2}/, '');
30
+ if (c.length !== 6) return null;
31
+ return { r: parseInt(c.slice(0, 2), 16), g: parseInt(c.slice(2, 4), 16), b: parseInt(c.slice(4, 6), 16) };
32
+ }
33
+ function lum({ r, g, b }) {
34
+ const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
35
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
36
+ }
37
+ function isDark(hex) { const r = hexToRgb(hex); return r ? lum(r) < 0.179 : null; }
38
+
39
+ function normalizeThemeEntry(raw) {
40
+ if (!raw || !raw.id) return null;
41
+ const gradients = parseGradient(raw.gradient_colors || raw.gradientColors);
42
+ const bg = raw.background_asset?.image?.uri || raw.backgroundImage || null;
43
+ const primary = gradients[0] || raw.fallback_color || raw.fallbackColor || null;
44
+
45
+ return {
46
+ id: raw.id,
47
+ name: raw.accessibility_label || raw.name || '',
48
+ description: raw.description || null,
49
+
50
+ // Colors
51
+ primary_color: primary,
52
+ fallback_color: raw.fallback_color || raw.fallbackColor || null,
53
+ gradient_colors: gradients,
54
+ background_gradient_colors: parseGradient(raw.background_gradient_colors || raw.backgroundGradientColors),
55
+ inbound_message_gradient_colors: parseGradient(raw.inbound_message_gradient_colors || raw.inboundMessageGradientColors),
56
+ message_text_color: raw.message_text_color || raw.messageTextColor || null,
57
+ inbound_message_text_color: raw.inbound_message_text_color || raw.inboundMessageTextColor || null,
58
+ title_bar_text_color: raw.title_bar_text_color || raw.titleBarTextColor || null,
59
+ title_bar_button_tint_color: raw.title_bar_button_tint_color || raw.titleBarButtonTintColor || null,
60
+ title_bar_background_color: raw.title_bar_background_color || raw.titleBarBackgroundColor || null,
61
+ title_bar_attribution_color: raw.title_bar_attribution_color || raw.titleBarAttributionColor || null,
62
+ composer_background_color: raw.composer_background_color || raw.composerBackgroundColor || null,
63
+ composer_input_background_color: raw.composer_input_background_color || raw.composerInputBackgroundColor || null,
64
+ composer_tint_color: raw.composer_tint_color || raw.composerTintColor || null,
65
+ primary_button_background_color: raw.primary_button_background_color || raw.primaryButtonBackgroundColor || null,
66
+ reaction_pill_background_color: raw.reaction_pill_background_color || raw.reactionPillBackgroundColor || null,
67
+ secondary_text_color: raw.secondary_text_color || raw.secondaryTextColor || null,
68
+ tertiary_text_color: raw.tertiary_text_color || raw.tertiaryTextColor || null,
69
+ hot_like_color: raw.hot_like_color || raw.hotLikeColor || null,
70
+ app_color_mode: raw.app_color_mode || raw.appColorMode || null,
71
+ normal_theme_id: raw.normal_theme_id || raw.normalThemeId || null,
72
+ theme_idx: raw.theme_idx,
73
+
74
+ // Images
75
+ backgroundImage: bg,
76
+ iconImage: raw.icon_asset?.image?.uri || raw.iconAsset || null,
77
+ background_asset: raw.background_asset || null,
78
+ icon_asset: raw.icon_asset || null,
79
+
80
+ // Derived
81
+ is_dark: isDark(primary),
82
+ };
83
+ }
84
+
85
+ module.exports = function (defaultFuncs, api, ctx) {
86
+
87
+ /**
88
+ * Fetch the full theme catalogue for a Messenger thread.
89
+ *
90
+ * @param {string} threadID Target thread ID
91
+ * @param {object} [options]
92
+ * @param {boolean} [options.resolveDetails=true] Fetch full metadata for each theme in parallel
93
+ * @param {boolean} [options.useCache=true] Use catalogue cache
94
+ * @param {string} [options.search] If set, return only themes matching this string
95
+ * @param {string} [options.searchColor] If set, return only themes whose gradient starts with this color
96
+ * @param {Function} [callback] Node-style (err, themes[]) callback
97
+ * @returns {Promise<Array>} Fully normalised theme catalogue
98
+ */
99
+ return async function getTheme(threadID, options, callback) {
100
+ if (typeof options === 'function') { callback = options; options = {}; }
101
+ options = options || {};
102
+
103
+ if (!threadID) {
104
+ const err = new Error("getTheme: threadID is required");
105
+ if (callback) return callback(err);
106
+ throw err;
107
+ }
108
+
109
+ let resolveFunc, rejectFunc;
110
+ const promise = new Promise((res, rej) => { resolveFunc = res; rejectFunc = rej; });
111
+
112
+ function done(err, data) {
113
+ if (callback) return err ? callback(err) : callback(null, data);
114
+ if (err) rejectFunc(err); else resolveFunc(data);
115
+ }
116
+
117
+ // Cache
118
+ const cacheKey = `catalogue::${threadID}`;
119
+ if (options.useCache !== false) {
120
+ const hit = _catalogueCache.get(cacheKey);
121
+ if (hit && Date.now() - hit.ts < CACHE_TTL) {
122
+ utils.log("getTheme", `Cache hit for thread ${threadID}`);
123
+ const data = applyFilters(hit.data, options);
124
+ done(null, data);
125
+ return promise;
126
+ }
127
+ }
128
+
129
+ const form = {
130
+ av: ctx.userID,
131
+ __user: ctx.userID,
132
+ fb_dtsg: ctx.fb_dtsg,
133
+ lsd: ctx.lsd || ctx.fb_dtsg,
134
+ fb_api_caller_class: 'RelayModern',
135
+ fb_api_req_friendly_name: 'MWPThreadThemeQuery_AllThemesQuery',
136
+ variables: JSON.stringify({ version: "default" }),
137
+ server_timestamps: true,
138
+ doc_id: '24474714052117636',
139
+ };
140
+
141
+ try {
142
+ const resData = await defaultFuncs
143
+ .post("https://www.facebook.com/api/graphql/", ctx.jar, form, null, {
144
+ "x-fb-friendly-name": "MWPThreadThemeQuery_AllThemesQuery",
145
+ "x-fb-lsd": ctx.lsd,
146
+ "referer": `https://www.facebook.com/messages/t/${threadID}`,
147
+ })
148
+ .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
149
+
150
+ if (resData.errors) throw new Error(JSON.stringify(resData.errors));
151
+ if (!resData.data?.messenger_thread_themes) throw new Error("getTheme: No theme data in response");
152
+
153
+ const rawList = resData.data.messenger_thread_themes;
154
+ const baseThemes = rawList.map(normalizeThemeEntry).filter(Boolean);
155
+
156
+ let themes = baseThemes;
157
+
158
+ // Parallel detail resolution
159
+ if (options.resolveDetails !== false && api.fetchThemeData) {
160
+ themes = await Promise.all(
161
+ baseThemes.map(async base => {
162
+ try {
163
+ const detail = await api.fetchThemeData(base.id, { useCache: true });
164
+ return { ...base, ...detail };
165
+ } catch {
166
+ return base;
167
+ }
168
+ })
169
+ );
170
+ }
171
+
172
+ // Cache
173
+ if (options.useCache !== false) {
174
+ _catalogueCache.set(cacheKey, { data: themes, ts: Date.now() });
175
+ }
176
+
177
+ utils.log("getTheme", `Loaded ${themes.length} theme(s) for thread ${threadID}`);
178
+ done(null, applyFilters(themes, options));
179
+ } catch (err) {
180
+ utils.error("getTheme", err.message || err);
181
+ done(err);
182
+ }
183
+
184
+ return promise;
185
+ };
186
+
187
+ function applyFilters(themes, options) {
188
+ let out = themes;
189
+ if (options.search) {
190
+ const q = options.search.toLowerCase();
191
+ out = out.filter(t => (t.name || '').toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q));
192
+ }
193
+ if (options.searchColor) {
194
+ const col = options.searchColor.toLowerCase().replace(/^#/, '');
195
+ out = out.filter(t => (t.gradient_colors || []).some(c => c.toLowerCase().includes(col)));
196
+ }
197
+ return out;
198
+ }
199
+ };
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * getThemeInfo — Advanced Thread / Theme Info Fetcher for Phantom SDK
5
+ *
6
+ * Resolves either a theme ID or a thread ID:
7
+ * - Theme ID → calls fetchThemeData for full theme metadata
8
+ * - Thread ID → calls getThreadInfo + optionally getTheme for active theme details
9
+ *
10
+ * Features:
11
+ * - Smart ID detection (theme vs thread)
12
+ * - Combined thread + active-theme metadata
13
+ * - In-memory cache (6-minute TTL)
14
+ * - Graceful degradation with sensible defaults on partial failures
15
+ * - Full callback + Promise dual API
16
+ */
17
+
18
+ const utils = require('../datastore/models/matrix/tools');
19
+
20
+ const _cache = new Map();
21
+ const CACHE_TTL = 6 * 60 * 1000; // 6 minutes
22
+
23
+ const THEME_ID_RE = /^\d{10,}$/;
24
+ const THREAD_ID_RE = /^\d{7,}$/;
25
+
26
+ function defaultThemeInfo(identifier, extras) {
27
+ return Object.assign({
28
+ threadID: identifier,
29
+ threadName: '',
30
+ participantCount: 0,
31
+ isGroup: null,
32
+ color: null,
33
+ emoji: '👍',
34
+ theme_id: null,
35
+ theme_color: null,
36
+ gradient_colors: null,
37
+ backgroundImage: null,
38
+ is_dark: null,
39
+ is_default: true,
40
+ }, extras || {});
41
+ }
42
+
43
+ module.exports = function (defaultFuncs, api, ctx) {
44
+
45
+ /**
46
+ * Get combined theme + thread info for a thread ID, or full theme data for a theme ID.
47
+ *
48
+ * @param {string} identifier Thread ID or Theme ID
49
+ * @param {object} [options]
50
+ * @param {boolean} [options.useCache=true] Use 6-minute cache
51
+ * @param {boolean} [options.fetchActiveTheme=true] Also resolve active theme details when threadID is given
52
+ * @param {Function} [callback] Node-style (err, info) callback
53
+ * @returns {Promise<object>} Resolved info object
54
+ */
55
+ return async function getThemeInfo(identifier, options, callback) {
56
+ if (typeof options === 'function') { callback = options; options = {}; }
57
+ options = options || {};
58
+
59
+ if (!identifier) {
60
+ const err = new Error("getThemeInfo: identifier is required (threadID or themeID)");
61
+ if (callback) return callback(err);
62
+ throw err;
63
+ }
64
+
65
+ let resolveFunc, rejectFunc;
66
+ const promise = new Promise((res, rej) => { resolveFunc = res; rejectFunc = rej; });
67
+
68
+ function done(err, data) {
69
+ if (callback) return err ? callback(err) : callback(null, data);
70
+ if (err) rejectFunc(err); else resolveFunc(data);
71
+ }
72
+
73
+ const id = identifier.toString().trim();
74
+ const cacheKey = `themeinfo::${id}`;
75
+
76
+ if (options.useCache !== false) {
77
+ const hit = _cache.get(cacheKey);
78
+ if (hit && Date.now() - hit.ts < CACHE_TTL) {
79
+ done(null, hit.data);
80
+ return promise;
81
+ }
82
+ }
83
+
84
+ // ── Detect if this is a pure theme ID ───────────────────────────────────
85
+ if (THEME_ID_RE.test(id) && api.fetchThemeData) {
86
+ try {
87
+ const themeData = await api.fetchThemeData(id, { useCache: options.useCache !== false });
88
+ const result = Object.assign({ _type: 'theme' }, themeData);
89
+ if (options.useCache !== false) _cache.set(cacheKey, { data: result, ts: Date.now() });
90
+ done(null, result);
91
+ } catch (themeErr) {
92
+ // Could still be a thread ID — fall through
93
+ utils.warn("getThemeInfo", `Theme fetch failed for ${id}: ${themeErr.message} — trying as thread`);
94
+ await resolveAsThread(id, options, done, cacheKey);
95
+ }
96
+ return promise;
97
+ }
98
+
99
+ // ── Treat as thread ID ───────────────────────────────────────────────────
100
+ await resolveAsThread(id, options, done, cacheKey);
101
+ return promise;
102
+
103
+ async function resolveAsThread(tid, opts, finish, ck) {
104
+ let threadInfo = null;
105
+ try {
106
+ threadInfo = await api.getThreadInfo(tid);
107
+ } catch (tiErr) {
108
+ utils.warn("getThemeInfo", `getThreadInfo failed: ${tiErr.message}`);
109
+ const fallback = defaultThemeInfo(tid, { error: tiErr.message || 'Could not retrieve thread info' });
110
+ if (opts.useCache !== false) _cache.set(ck, { data: fallback, ts: Date.now() });
111
+ finish(null, fallback);
112
+ return;
113
+ }
114
+
115
+ if (!threadInfo) {
116
+ const fallback = defaultThemeInfo(tid);
117
+ if (opts.useCache !== false) _cache.set(ck, { data: fallback, ts: Date.now() });
118
+ finish(null, fallback);
119
+ return;
120
+ }
121
+
122
+ const info = Array.isArray(threadInfo) ? threadInfo[0] : threadInfo;
123
+
124
+ const base = {
125
+ _type: 'thread',
126
+ threadID: tid,
127
+ threadName: info.threadName || info.name || '',
128
+ participantCount: info.participantIDs?.length || 0,
129
+ isGroup: info.isGroup || false,
130
+ color: info.color || null,
131
+ emoji: info.emoji || '👍',
132
+ theme_id: info.theme_id || info.themeID || null,
133
+ theme_color: info.theme_color || info.color || null,
134
+ gradient_colors: info.gradient_colors || null,
135
+ backgroundImage: info.backgroundImage || null,
136
+ is_dark: null,
137
+ is_default: !info.color && !info.theme_id,
138
+ muteUntil: info.muteUntil || null,
139
+ unreadCount: info.unreadCount || 0,
140
+ };
141
+
142
+ // Try to resolve the active theme's full details
143
+ if (opts.fetchActiveTheme !== false && base.theme_id && api.fetchThemeData) {
144
+ try {
145
+ const activeTheme = await api.fetchThemeData(base.theme_id, { useCache: true });
146
+ Object.assign(base, {
147
+ active_theme: activeTheme,
148
+ gradient_colors: activeTheme.gradient_colors || base.gradient_colors,
149
+ backgroundImage: activeTheme.backgroundImage || base.backgroundImage,
150
+ is_dark: activeTheme.is_dark,
151
+ theme_color: activeTheme.primary_color || base.theme_color,
152
+ });
153
+ } catch (_) {}
154
+ }
155
+
156
+ if (opts.useCache !== false) _cache.set(ck, { data: base, ts: Date.now() });
157
+ finish(null, base);
158
+ }
159
+ };
160
+ };