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.
- package/LICENSE +58 -0
- package/README.md +534 -0
- package/index.js +35 -0
- package/package.json +101 -0
- package/phantom/core/builder/bootstrap.js +334 -0
- package/phantom/core/builder/config.js +78 -0
- package/phantom/core/builder/forge.js +113 -0
- package/phantom/core/builder/ignite.js +386 -0
- package/phantom/core/builder/options.js +61 -0
- package/phantom/core/engine.js +71 -0
- package/phantom/core/reactor.js +2 -0
- package/phantom/datastore/appState.js +2 -0
- package/phantom/datastore/appStateBackup.js +34 -0
- package/phantom/datastore/models/cipher/e2ee.js +48 -0
- package/phantom/datastore/models/cipher/vault.js +153 -0
- package/phantom/datastore/models/index.js +3 -0
- package/phantom/datastore/models/matrix/auth.js +151 -0
- package/phantom/datastore/models/matrix/cache.js +3 -0
- package/phantom/datastore/models/matrix/checker.js +2 -0
- package/phantom/datastore/models/matrix/clients.js +2 -0
- package/phantom/datastore/models/matrix/constants.js +2 -0
- package/phantom/datastore/models/matrix/credentials.js +2 -0
- package/phantom/datastore/models/matrix/cycle.js +2 -0
- package/phantom/datastore/models/matrix/gate.js +282 -0
- package/phantom/datastore/models/matrix/ghost.js +332 -0
- package/phantom/datastore/models/matrix/headers.js +193 -0
- package/phantom/datastore/models/matrix/heartbeat.js +298 -0
- package/phantom/datastore/models/matrix/identity.js +235 -0
- package/phantom/datastore/models/matrix/logger.js +271 -0
- package/phantom/datastore/models/matrix/monitor.js +2 -0
- package/phantom/datastore/models/matrix/net.js +316 -0
- package/phantom/datastore/models/matrix/response.js +193 -0
- package/phantom/datastore/models/matrix/revive.js +255 -0
- package/phantom/datastore/models/matrix/signals.js +2 -0
- package/phantom/datastore/models/matrix/store.js +263 -0
- package/phantom/datastore/models/matrix/telemetry.js +272 -0
- package/phantom/datastore/models/matrix/tools.js +93 -0
- package/phantom/datastore/models/matrix/transform/cookieParser.js +2 -0
- package/phantom/datastore/models/matrix/transform/cookies.js +114 -0
- package/phantom/datastore/models/matrix/transform/index.js +203 -0
- package/phantom/datastore/models/matrix/validator.js +157 -0
- package/phantom/datastore/models/types/index.d.ts +498 -0
- package/phantom/datastore/schema.js +167 -0
- package/phantom/datastore/session.js +129 -0
- package/phantom/datastore/threads.js +22 -0
- package/phantom/datastore/users.js +26 -0
- package/phantom/dispatch/addExternalModule.js +239 -0
- package/phantom/dispatch/addUserToGroup.js +161 -0
- package/phantom/dispatch/changeAdminStatus.js +142 -0
- package/phantom/dispatch/changeArchivedStatus.js +135 -0
- package/phantom/dispatch/changeAvatar.js +123 -0
- package/phantom/dispatch/changeBio.js +86 -0
- package/phantom/dispatch/changeBlockedStatus.js +86 -0
- package/phantom/dispatch/changeGroupImage.js +145 -0
- package/phantom/dispatch/changeThreadColor.js +172 -0
- package/phantom/dispatch/changeThreadEmoji.js +130 -0
- package/phantom/dispatch/comment.js +136 -0
- package/phantom/dispatch/createAITheme.js +333 -0
- package/phantom/dispatch/createNewGroup.js +99 -0
- package/phantom/dispatch/createPoll.js +148 -0
- package/phantom/dispatch/deleteMessage.js +131 -0
- package/phantom/dispatch/deleteThread.js +155 -0
- package/phantom/dispatch/e2ee.js +101 -0
- package/phantom/dispatch/editMessage.js +158 -0
- package/phantom/dispatch/emoji.js +143 -0
- package/phantom/dispatch/fetchThemeData.js +233 -0
- package/phantom/dispatch/follow.js +111 -0
- package/phantom/dispatch/forwardMessage.js +110 -0
- package/phantom/dispatch/friend.js +189 -0
- package/phantom/dispatch/gcmember.js +138 -0
- package/phantom/dispatch/gcname.js +131 -0
- package/phantom/dispatch/gcrule.js +111 -0
- package/phantom/dispatch/getAccess.js +109 -0
- package/phantom/dispatch/getBotInfo.js +81 -0
- package/phantom/dispatch/getBotInitialData.js +110 -0
- package/phantom/dispatch/getFriendsList.js +118 -0
- package/phantom/dispatch/getMessage.js +199 -0
- package/phantom/dispatch/getTheme.js +199 -0
- package/phantom/dispatch/getThemeInfo.js +160 -0
- package/phantom/dispatch/getThreadHistory.js +139 -0
- package/phantom/dispatch/getThreadInfo.js +153 -0
- package/phantom/dispatch/getThreadList.js +132 -0
- package/phantom/dispatch/getThreadPictures.js +93 -0
- package/phantom/dispatch/getUserID.js +147 -0
- package/phantom/dispatch/getUserInfo.js +513 -0
- package/phantom/dispatch/getUserInfoV2.js +146 -0
- package/phantom/dispatch/handleMessageRequest.js +50 -0
- package/phantom/dispatch/httpGet.js +63 -0
- package/phantom/dispatch/httpPost.js +89 -0
- package/phantom/dispatch/httpPostFormData.js +69 -0
- package/phantom/dispatch/listenMqtt.js +1236 -0
- package/phantom/dispatch/listenSpeed.js +179 -0
- package/phantom/dispatch/logout.js +93 -0
- package/phantom/dispatch/markAsDelivered.js +92 -0
- package/phantom/dispatch/markAsRead.js +119 -0
- package/phantom/dispatch/markAsReadAll.js +215 -0
- package/phantom/dispatch/markAsSeen.js +70 -0
- package/phantom/dispatch/mqttDeltaValue.js +278 -0
- package/phantom/dispatch/muteThread.js +253 -0
- package/phantom/dispatch/nickname.js +132 -0
- package/phantom/dispatch/notes.js +263 -0
- package/phantom/dispatch/pinMessage.js +238 -0
- package/phantom/dispatch/produceMetaTheme.js +335 -0
- package/phantom/dispatch/realtime.js +291 -0
- package/phantom/dispatch/removeUserFromGroup.js +248 -0
- package/phantom/dispatch/resolvePhotoUrl.js +217 -0
- package/phantom/dispatch/searchForThread.js +258 -0
- package/phantom/dispatch/sendMessage.js +354 -0
- package/phantom/dispatch/sendMessageMqtt.js +249 -0
- package/phantom/dispatch/sendTypingIndicator.js +206 -0
- package/phantom/dispatch/setMessageReaction.js +188 -0
- package/phantom/dispatch/setMessageReactionMqtt.js +248 -0
- package/phantom/dispatch/setThreadTheme.js +330 -0
- package/phantom/dispatch/setThreadThemeMqtt.js +207 -0
- package/phantom/dispatch/share.js +200 -0
- package/phantom/dispatch/shareContact.js +216 -0
- package/phantom/dispatch/stickers.js +395 -0
- package/phantom/dispatch/story.js +240 -0
- package/phantom/dispatch/theme.js +296 -0
- package/phantom/dispatch/unfriend.js +199 -0
- package/phantom/dispatch/unsendMessage.js +124 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const utils = require('../datastore/models/matrix/tools');
|
|
4
|
+
const { globalShield } = require('../datastore/models/matrix/ghost');
|
|
5
|
+
|
|
6
|
+
module.exports = (defaultFuncs, api, ctx) => {
|
|
7
|
+
function detectAttachmentType(attachment) {
|
|
8
|
+
const p = attachment.path || "";
|
|
9
|
+
const ext = p.toLowerCase().split(".").pop();
|
|
10
|
+
const audio = ["mp3", "wav", "aac", "m4a", "ogg", "opus", "flac"];
|
|
11
|
+
const video = ["mp4", "mov", "avi", "mkv", "webm", "wmv", "flv"];
|
|
12
|
+
const image = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
|
|
13
|
+
if (audio.includes(ext)) return { voice_clip: "true" };
|
|
14
|
+
if (video.includes(ext)) return { video: "true" };
|
|
15
|
+
if (image.includes(ext)) return { image: "true" };
|
|
16
|
+
return { file: "true" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function uploadAttachment(attachments, callback) {
|
|
20
|
+
callback = callback || function () {};
|
|
21
|
+
var uploads = [];
|
|
22
|
+
try {
|
|
23
|
+
for (var i = 0; i < attachments.length; i++) {
|
|
24
|
+
if (!utils.isReadableStream(attachments[i])) {
|
|
25
|
+
throw { error: "Attachment should be a readable stream and not " + utils.getType(attachments[i]) + "." };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (i > 0) {
|
|
29
|
+
await globalShield.addSmartDelay();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var form = {
|
|
33
|
+
upload_1024: attachments[i],
|
|
34
|
+
...detectAttachmentType(attachments[i]),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const upload = await defaultFuncs
|
|
38
|
+
.postFormData("https://upload.facebook.com/ajax/mercury/upload.php", ctx.jar, form, {}, { ...ctx, requestThreadID: String(ctx._lastThreadHint || "") })
|
|
39
|
+
.then(utils.parseAndCheckLogin(ctx, defaultFuncs))
|
|
40
|
+
.then(resData => {
|
|
41
|
+
if (resData.error) throw resData;
|
|
42
|
+
return resData.payload.metadata[0];
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
uploads.push(upload);
|
|
46
|
+
}
|
|
47
|
+
callback(null, uploads);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
utils.error("uploadAttachment", err);
|
|
50
|
+
return callback(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getSendPayload(threadID, msg, otid) {
|
|
55
|
+
const isString = typeof msg === 'string';
|
|
56
|
+
const body = isString ? msg : msg.body || "";
|
|
57
|
+
otid = otid.toString() || utils.generateOfflineThreadingID().toString();
|
|
58
|
+
let payload = {
|
|
59
|
+
thread_id: threadID.toString(),
|
|
60
|
+
otid,
|
|
61
|
+
source: 0,
|
|
62
|
+
send_type: 1,
|
|
63
|
+
sync_group: 1,
|
|
64
|
+
text: body,
|
|
65
|
+
initiating_source: 1,
|
|
66
|
+
skip_url_preview_gen: 0,
|
|
67
|
+
};
|
|
68
|
+
if (typeof msg === 'object') {
|
|
69
|
+
if (msg.sticker) {
|
|
70
|
+
payload.send_type = 2;
|
|
71
|
+
payload.sticker_id = msg.sticker;
|
|
72
|
+
payload.text = null;
|
|
73
|
+
}
|
|
74
|
+
if (msg.attachment) {
|
|
75
|
+
payload.send_type = 3;
|
|
76
|
+
payload.attachment_fbids = Array.isArray(msg.attachment) ? msg.attachment : [msg.attachment];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractIdsFromPayload(payload) {
|
|
83
|
+
let messageID = null;
|
|
84
|
+
let threadID = null;
|
|
85
|
+
function walk(n) {
|
|
86
|
+
if (Array.isArray(n)) {
|
|
87
|
+
if (n[0] === 5 && (n[1] === "replaceOptimsiticMessage" || n[1] === "replaceOptimisticMessage")) {
|
|
88
|
+
messageID = String(n[3]);
|
|
89
|
+
}
|
|
90
|
+
if (n[0] === 5 && n[1] === "writeCTAIdToThreadsTable") {
|
|
91
|
+
const a = n[2];
|
|
92
|
+
if (Array.isArray(a) && a[0] === 19) threadID = String(a[1]);
|
|
93
|
+
}
|
|
94
|
+
for (const x of n) walk(x);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(payload?.step);
|
|
98
|
+
return { threadID, messageID };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publishWithAck(content, reqID, callback) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
if (!ctx.mqttClient || typeof ctx.mqttClient.on !== "function" || typeof ctx.mqttClient.publish !== "function") {
|
|
104
|
+
const err = new Error("MQTT client is not initialized");
|
|
105
|
+
utils.error("sendMessageMqtt", err);
|
|
106
|
+
callback && callback(err);
|
|
107
|
+
return reject(err);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof ctx.mqttClient.setMaxListeners === "function") {
|
|
111
|
+
ctx.mqttClient.setMaxListeners(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let done = false;
|
|
115
|
+
const cleanup = () => {
|
|
116
|
+
if (done) return;
|
|
117
|
+
done = true;
|
|
118
|
+
ctx.mqttClient.removeListener("message", handleRes);
|
|
119
|
+
};
|
|
120
|
+
const handleRes = (topic, message) => {
|
|
121
|
+
if (topic !== "/ls_resp") return;
|
|
122
|
+
let jsonMsg;
|
|
123
|
+
try {
|
|
124
|
+
jsonMsg = JSON.parse(message.toString());
|
|
125
|
+
jsonMsg.payload = JSON.parse(jsonMsg.payload);
|
|
126
|
+
} catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (jsonMsg.request_id !== reqID) return;
|
|
130
|
+
const { threadID, messageID } = extractIdsFromPayload(jsonMsg.payload);
|
|
131
|
+
const result = { messageID, threadID };
|
|
132
|
+
cleanup();
|
|
133
|
+
callback && callback(undefined, result);
|
|
134
|
+
resolve(result);
|
|
135
|
+
};
|
|
136
|
+
ctx.mqttClient.on("message", handleRes);
|
|
137
|
+
ctx.mqttClient.publish("/ls_req", JSON.stringify(content), { qos: 1, retain: false }, err => {
|
|
138
|
+
if (err) {
|
|
139
|
+
cleanup();
|
|
140
|
+
callback && callback(err);
|
|
141
|
+
reject(err);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
if (done) return;
|
|
146
|
+
cleanup();
|
|
147
|
+
const err = { error: "Timeout waiting for ACK" };
|
|
148
|
+
callback && callback(err);
|
|
149
|
+
reject(err);
|
|
150
|
+
}, 15000);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return async (msg, threadID, replyToMessage, callback) => {
|
|
155
|
+
if (typeof msg !== 'string' && typeof msg !== 'object') {
|
|
156
|
+
throw new Error("Message should be of type string or object, not " + utils.getType(msg) + ".");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof threadID !== 'string' && typeof threadID !== 'number') {
|
|
160
|
+
throw new Error("threadID must be a string or number.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!callback && typeof threadID === "function") {
|
|
164
|
+
throw new Error("Pass a threadID as a second argument.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!callback && typeof replyToMessage === "function") {
|
|
168
|
+
callback = replyToMessage;
|
|
169
|
+
replyToMessage = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Apply anti-suspension throttling and volume checks before every MQTT send
|
|
173
|
+
try {
|
|
174
|
+
await globalShield.prepareBeforeMessage(String(threadID), typeof msg === 'string' ? msg : (msg.body || ''));
|
|
175
|
+
} catch (suspErr) {
|
|
176
|
+
utils.warn("sendMessageMqtt", "Anti-suspension check raised:", suspErr && suspErr.message ? suspErr.message : suspErr);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const timestamp = Date.now();
|
|
180
|
+
const otid = utils.generateOfflineThreadingID();
|
|
181
|
+
const epoch_id = utils.generateOfflineThreadingID();
|
|
182
|
+
const payload = getSendPayload(threadID, msg, otid);
|
|
183
|
+
|
|
184
|
+
const tasks = [{
|
|
185
|
+
label: "46",
|
|
186
|
+
payload,
|
|
187
|
+
queue_name: threadID.toString(),
|
|
188
|
+
task_id: 0,
|
|
189
|
+
failure_count: null,
|
|
190
|
+
}, {
|
|
191
|
+
label: "21",
|
|
192
|
+
payload: {
|
|
193
|
+
thread_id: threadID.toString(),
|
|
194
|
+
last_read_watermark_ts: timestamp,
|
|
195
|
+
sync_group: 1,
|
|
196
|
+
},
|
|
197
|
+
queue_name: threadID.toString(),
|
|
198
|
+
task_id: 1,
|
|
199
|
+
failure_count: null,
|
|
200
|
+
}];
|
|
201
|
+
|
|
202
|
+
if (replyToMessage) {
|
|
203
|
+
tasks[0].payload.reply_metadata = {
|
|
204
|
+
reply_source_id: replyToMessage,
|
|
205
|
+
reply_source_type: 1,
|
|
206
|
+
reply_type: 0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const request_id = ++ctx.wsReqNumber;
|
|
211
|
+
const form = {
|
|
212
|
+
app_id: "2220391788200892",
|
|
213
|
+
payload: {
|
|
214
|
+
tasks,
|
|
215
|
+
epoch_id,
|
|
216
|
+
version_id: "6120284488008082",
|
|
217
|
+
data_trace_id: null,
|
|
218
|
+
},
|
|
219
|
+
request_id,
|
|
220
|
+
type: 3,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (msg.attachment) {
|
|
224
|
+
try {
|
|
225
|
+
ctx._lastThreadHint = threadID;
|
|
226
|
+
const files = await new Promise((resolve, reject) => {
|
|
227
|
+
uploadAttachment(
|
|
228
|
+
Array.isArray(msg.attachment) ? msg.attachment : [msg.attachment],
|
|
229
|
+
(err, files) => {
|
|
230
|
+
if (err) return reject(err);
|
|
231
|
+
return resolve(files);
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
form.payload.tasks[0].payload.attachment_fbids = files.map(file => Object.values(file)[0]);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
utils.error("Attachment upload failed:", err);
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
form.payload.tasks.forEach(task => {
|
|
243
|
+
task.payload = JSON.stringify(task.payload);
|
|
244
|
+
});
|
|
245
|
+
form.payload = JSON.stringify(form.payload);
|
|
246
|
+
|
|
247
|
+
return publishWithAck(form, request_id, callback);
|
|
248
|
+
};
|
|
249
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const utils = require('../datastore/models/matrix/tools');
|
|
4
|
+
const { globalShield } = require('../datastore/models/matrix/ghost');
|
|
5
|
+
|
|
6
|
+
const TYPING_STATE = new Map();
|
|
7
|
+
const DEBOUNCE_TIMERS = new Map();
|
|
8
|
+
const AUTO_STOP_TIMERS = new Map();
|
|
9
|
+
const TYPING_METRICS = { sent: 0, errors: 0, debounced: 0 };
|
|
10
|
+
const RATE_LIMIT = new Map();
|
|
11
|
+
const RATE_LIMIT_TTL = 1000;
|
|
12
|
+
const RATE_LIMIT_MAX = 3;
|
|
13
|
+
|
|
14
|
+
function getThreadCache(ctx) {
|
|
15
|
+
if (!ctx.threadTypeCache) ctx.threadTypeCache = Object.create(null);
|
|
16
|
+
return ctx.threadTypeCache;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function isGroupThread(ctx, api, threadID) {
|
|
20
|
+
const tid = String(threadID);
|
|
21
|
+
const cache = getThreadCache(ctx);
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(cache, tid)) return !!cache[tid];
|
|
23
|
+
try {
|
|
24
|
+
const info = await api.getThreadInfo(tid);
|
|
25
|
+
cache[tid] = !!info.isGroup;
|
|
26
|
+
return !!info.isGroup;
|
|
27
|
+
} catch (_) {
|
|
28
|
+
const fallback = tid.length >= 16;
|
|
29
|
+
cache[tid] = fallback;
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRateLimited(threadID) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const key = String(threadID);
|
|
37
|
+
const entry = RATE_LIMIT.get(key);
|
|
38
|
+
if (!entry || now - entry.windowStart > RATE_LIMIT_TTL) {
|
|
39
|
+
RATE_LIMIT.set(key, { count: 1, windowStart: now });
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (entry.count >= RATE_LIMIT_MAX) return true;
|
|
43
|
+
entry.count++;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function publishTyping(ctx, isGroup, threadID, sendTyping) {
|
|
48
|
+
const wsContent = {
|
|
49
|
+
app_id: 2220391788200892,
|
|
50
|
+
payload: JSON.stringify({
|
|
51
|
+
label: 3,
|
|
52
|
+
payload: JSON.stringify({
|
|
53
|
+
thread_key: String(threadID),
|
|
54
|
+
is_group_thread: +isGroup,
|
|
55
|
+
is_typing: +sendTyping,
|
|
56
|
+
attribution: 0
|
|
57
|
+
}),
|
|
58
|
+
version: 5849951561777440
|
|
59
|
+
}),
|
|
60
|
+
request_id: ++ctx.wsReqNumber,
|
|
61
|
+
type: 4
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const timer = setTimeout(() => reject(new Error('sendTypingIndicator: MQTT publish timed out')), 8000);
|
|
66
|
+
ctx.mqttClient.publish('/ls_req', JSON.stringify(wsContent), { qos: 1 }, (err) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
if (err) reject(err);
|
|
69
|
+
else resolve();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = function (defaultFuncs, api, ctx) {
|
|
75
|
+
|
|
76
|
+
async function sendTypingIndicatorCore(sendTyping, threadID) {
|
|
77
|
+
if (!ctx.mqttClient) throw new Error('sendTypingIndicator: MQTT not connected (start listening first)');
|
|
78
|
+
|
|
79
|
+
const tid = String(threadID);
|
|
80
|
+
const currentState = TYPING_STATE.get(tid);
|
|
81
|
+
if (currentState === sendTyping) {
|
|
82
|
+
TYPING_METRICS.debounced++;
|
|
83
|
+
return { skipped: true, reason: 'state_unchanged', threadID: tid };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isRateLimited(tid)) {
|
|
87
|
+
TYPING_METRICS.debounced++;
|
|
88
|
+
return { skipped: true, reason: 'rate_limited', threadID: tid };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let isGroup;
|
|
92
|
+
try {
|
|
93
|
+
isGroup = await isGroupThread(ctx, api, tid);
|
|
94
|
+
} catch (_) {
|
|
95
|
+
isGroup = tid.length >= 16;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await publishTyping(ctx, isGroup, tid, sendTyping);
|
|
99
|
+
|
|
100
|
+
TYPING_STATE.set(tid, sendTyping);
|
|
101
|
+
TYPING_METRICS.sent++;
|
|
102
|
+
utils.log('sendTypingIndicator', `Thread ${tid}: ${sendTyping ? 'start' : 'stop'} typing`);
|
|
103
|
+
return { success: true, threadID: tid, isTyping: sendTyping, isGroup };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return async function sendTypingIndicator(sendTyping, threadID, callback) {
|
|
107
|
+
const {
|
|
108
|
+
debounce = 0,
|
|
109
|
+
autoStop = null,
|
|
110
|
+
force = false
|
|
111
|
+
} = typeof callback === 'object' && callback !== null && !Array.isArray(callback)
|
|
112
|
+
? (callback = undefined, arguments[2])
|
|
113
|
+
: {};
|
|
114
|
+
|
|
115
|
+
let resolveFunc, rejectFunc;
|
|
116
|
+
const returnPromise = new Promise((resolve, reject) => {
|
|
117
|
+
resolveFunc = resolve;
|
|
118
|
+
rejectFunc = reject;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const done = (err, data) => {
|
|
122
|
+
if (callback) callback(err, data);
|
|
123
|
+
if (err) rejectFunc(err);
|
|
124
|
+
else resolveFunc(data);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (sendTyping !== true && sendTyping !== false) {
|
|
128
|
+
done(new Error('sendTypingIndicator: first argument must be boolean'));
|
|
129
|
+
return returnPromise;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tid = String(threadID);
|
|
133
|
+
|
|
134
|
+
if (debounce > 0 && !force) {
|
|
135
|
+
if (DEBOUNCE_TIMERS.has(tid)) {
|
|
136
|
+
clearTimeout(DEBOUNCE_TIMERS.get(tid));
|
|
137
|
+
TYPING_METRICS.debounced++;
|
|
138
|
+
}
|
|
139
|
+
DEBOUNCE_TIMERS.set(tid, setTimeout(async () => {
|
|
140
|
+
DEBOUNCE_TIMERS.delete(tid);
|
|
141
|
+
try {
|
|
142
|
+
const result = await sendTypingIndicatorCore(sendTyping, tid);
|
|
143
|
+
done(null, result);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
TYPING_METRICS.errors++;
|
|
146
|
+
utils.error('sendTypingIndicator', err);
|
|
147
|
+
done(err);
|
|
148
|
+
}
|
|
149
|
+
}, debounce));
|
|
150
|
+
return returnPromise;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (AUTO_STOP_TIMERS.has(tid)) {
|
|
155
|
+
clearTimeout(AUTO_STOP_TIMERS.get(tid));
|
|
156
|
+
AUTO_STOP_TIMERS.delete(tid);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await sendTypingIndicatorCore(sendTyping, tid);
|
|
160
|
+
|
|
161
|
+
if (sendTyping && autoStop && typeof autoStop === 'number' && autoStop > 0) {
|
|
162
|
+
const stopTimer = setTimeout(async () => {
|
|
163
|
+
AUTO_STOP_TIMERS.delete(tid);
|
|
164
|
+
try { await sendTypingIndicatorCore(false, tid); } catch (_) {}
|
|
165
|
+
}, autoStop);
|
|
166
|
+
AUTO_STOP_TIMERS.set(tid, stopTimer);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!sendTyping) {
|
|
170
|
+
TYPING_STATE.delete(tid);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
done(null, result);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
TYPING_METRICS.errors++;
|
|
176
|
+
utils.error('sendTypingIndicator', err);
|
|
177
|
+
done(err);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return returnPromise;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
Object.assign(module.exports, {
|
|
184
|
+
getMetrics: () => ({ ...TYPING_METRICS }),
|
|
185
|
+
getTypingThreads: () => Array.from(TYPING_STATE.entries()).filter(([, v]) => v).map(([k]) => k),
|
|
186
|
+
stopAllTyping: async () => {
|
|
187
|
+
const threads = Array.from(TYPING_STATE.entries()).filter(([, v]) => v).map(([k]) => k);
|
|
188
|
+
for (const tid of threads) {
|
|
189
|
+
try {
|
|
190
|
+
if (ctx.mqttClient) {
|
|
191
|
+
const isGroup = tid.length >= 16;
|
|
192
|
+
await publishTyping(ctx, isGroup, tid, false);
|
|
193
|
+
TYPING_STATE.set(tid, false);
|
|
194
|
+
}
|
|
195
|
+
} catch (_) {}
|
|
196
|
+
}
|
|
197
|
+
return { stopped: threads };
|
|
198
|
+
},
|
|
199
|
+
clearAllTimers: () => {
|
|
200
|
+
for (const t of DEBOUNCE_TIMERS.values()) clearTimeout(t);
|
|
201
|
+
for (const t of AUTO_STOP_TIMERS.values()) clearTimeout(t);
|
|
202
|
+
DEBOUNCE_TIMERS.clear();
|
|
203
|
+
AUTO_STOP_TIMERS.clear();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* setMessageReaction — Multi-Emoji Message Reaction Handler for Phantom SDK
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* 1. MQTT path (setMessageReactionMqtt) — preferred, low-latency
|
|
8
|
+
* 2. GraphQL webgraphql/mutation fallback — when MQTT unavailable or fails
|
|
9
|
+
* 3. Batch support: pass arrays of messageIDs
|
|
10
|
+
* 4. Multi-emoji support: pass array of emoji strings as `reaction`
|
|
11
|
+
* 5. Retry with exponential backoff on transient failures
|
|
12
|
+
* 6. Full callback + Promise dual API
|
|
13
|
+
*
|
|
14
|
+
* @param {string|string[]} reaction Single emoji, array of emojis, or "" to remove all.
|
|
15
|
+
* @param {string|string[]} messageID Message ID or array of message IDs
|
|
16
|
+
* @param {string} [threadID] Thread ID (needed for MQTT path)
|
|
17
|
+
* @param {object} [options] { retries, preferMqtt, delayMs }
|
|
18
|
+
* @param {Function} [callback] Node-style (err, result)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const utils = require('../datastore/models/matrix/tools');
|
|
22
|
+
|
|
23
|
+
const VALID_REACTIONS = new Set([
|
|
24
|
+
'😍', '😆', '😮', '😢', '😠', '😡', '😲', '😂', '😭',
|
|
25
|
+
'👍', '👎', '❤️', '🎉', '👏', '🔥', '🤔', '💯', '🥰',
|
|
26
|
+
'🙏', '🤣', '😊', '🥺', '💀', '😎', '🤯', '😴', '🤮',
|
|
27
|
+
'🔪', '🐒', '🦊', '🐱', '🎁', '💋', '💔', '❤', '🫶',
|
|
28
|
+
'🤝', '💪', '🙌', '👀', '🫠', '🤡', '💩', '👻', '🤖',
|
|
29
|
+
'',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const MULTI_REACT_DELAY_MS = 250;
|
|
33
|
+
|
|
34
|
+
async function graphqlReaction(defaultFuncs, ctx, reaction, messageID, retries, baseMs) {
|
|
35
|
+
const action = reaction === '' ? 'REMOVE_REACTION' : 'ADD_REACTION';
|
|
36
|
+
let lastErr;
|
|
37
|
+
|
|
38
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const defData = await defaultFuncs.postFormData(
|
|
41
|
+
"https://www.facebook.com/webgraphql/mutation/",
|
|
42
|
+
ctx.jar,
|
|
43
|
+
{},
|
|
44
|
+
{
|
|
45
|
+
doc_id: "1491398900900362",
|
|
46
|
+
variables: JSON.stringify({
|
|
47
|
+
data: {
|
|
48
|
+
client_mutation_id: String(ctx.clientMutationId != null ? ctx.clientMutationId++ : Math.floor(Math.random() * 1e9)),
|
|
49
|
+
actor_id: ctx.userID,
|
|
50
|
+
action,
|
|
51
|
+
message_id: messageID,
|
|
52
|
+
reaction,
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
dpr: 1,
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const resData = await utils.parseAndCheckLogin(ctx, defaultFuncs)(defData);
|
|
60
|
+
if (!resData) throw new Error("setMessageReaction: GraphQL returned empty object");
|
|
61
|
+
if (resData.error) throw new Error(JSON.stringify(resData.error));
|
|
62
|
+
return resData;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
lastErr = err;
|
|
65
|
+
if (attempt >= retries || /fatal|auth|checkpoint/i.test(err.message)) throw err;
|
|
66
|
+
const wait = baseMs * Math.pow(2, attempt - 1) + Math.random() * 200;
|
|
67
|
+
utils.warn("setMessageReaction", `Retry ${attempt}/${retries} for ${messageID} in ${Math.round(wait)}ms`);
|
|
68
|
+
await new Promise(r => setTimeout(r, wait));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw lastErr;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mqttReaction(ctx, reaction, messageID, threadID) {
|
|
75
|
+
if (!ctx.mqttClient) throw new Error("setMessageReaction: MQTT client not available");
|
|
76
|
+
|
|
77
|
+
ctx.wsReqNumber = (ctx.wsReqNumber || 0) + 1;
|
|
78
|
+
ctx.wsTaskNumber = (ctx.wsTaskNumber || 0) + 1;
|
|
79
|
+
|
|
80
|
+
const taskPayload = {
|
|
81
|
+
thread_key: threadID.toString(),
|
|
82
|
+
timestamp_ms: Date.now(),
|
|
83
|
+
message_id: messageID,
|
|
84
|
+
reaction,
|
|
85
|
+
actor_id: ctx.userID,
|
|
86
|
+
reaction_style: null,
|
|
87
|
+
sync_group: 1,
|
|
88
|
+
send_attribution: Math.random() < 0.5 ? 65537 : 524289,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const content = {
|
|
92
|
+
app_id: '2220391788200892',
|
|
93
|
+
payload: JSON.stringify({
|
|
94
|
+
data_trace_id: null,
|
|
95
|
+
epoch_id: parseInt(utils.generateOfflineThreadingID ? utils.generateOfflineThreadingID() : Date.now()),
|
|
96
|
+
tasks: [{
|
|
97
|
+
failure_count: null,
|
|
98
|
+
label: '29',
|
|
99
|
+
payload: JSON.stringify(taskPayload),
|
|
100
|
+
queue_name: JSON.stringify(['reaction', messageID]),
|
|
101
|
+
task_id: ctx.wsTaskNumber,
|
|
102
|
+
}],
|
|
103
|
+
version_id: '7158486590867448',
|
|
104
|
+
}),
|
|
105
|
+
request_id: ctx.wsReqNumber,
|
|
106
|
+
type: 3,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
ctx.mqttClient.publish('/ls_req', JSON.stringify(content), { qos: 1, retain: false }, err => {
|
|
111
|
+
if (err) reject(new Error(`MQTT publish failed: ${err.message}`));
|
|
112
|
+
else resolve({ success: true, method: 'mqtt', messageID, reaction });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function sendSingleReaction(defaultFuncs, ctx, reaction, msgId, threadID, options) {
|
|
118
|
+
const retries = options.retries || 3;
|
|
119
|
+
const baseMs = 500;
|
|
120
|
+
const useMqtt = options.preferMqtt !== false && ctx.mqttClient && !!threadID;
|
|
121
|
+
|
|
122
|
+
if (useMqtt) {
|
|
123
|
+
try {
|
|
124
|
+
return await mqttReaction(ctx, reaction, msgId, threadID);
|
|
125
|
+
} catch (mqttErr) {
|
|
126
|
+
utils.warn("setMessageReaction", `MQTT failed for ${msgId} — falling back to GraphQL: ${mqttErr.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return await graphqlReaction(defaultFuncs, ctx, reaction, msgId, retries, baseMs);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = function (defaultFuncs, api, ctx) {
|
|
133
|
+
|
|
134
|
+
return async function setMessageReaction(reaction, messageID, threadID, options, callback) {
|
|
135
|
+
if (typeof threadID === 'object' && !Array.isArray(threadID)) { options = threadID; threadID = null; }
|
|
136
|
+
if (typeof threadID === 'function') { callback = threadID; threadID = null; options = {}; }
|
|
137
|
+
if (typeof options === 'function') { callback = options; options = {}; }
|
|
138
|
+
options = options || {};
|
|
139
|
+
|
|
140
|
+
if (reaction === undefined || reaction === null) {
|
|
141
|
+
throw new Error("setMessageReaction: reaction is required (emoji, array of emojis, or '' to remove)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const reactions = Array.isArray(reaction) ? reaction : [reaction];
|
|
145
|
+
const ids = Array.isArray(messageID) ? messageID : [messageID];
|
|
146
|
+
const delayMs = options.delayMs ?? MULTI_REACT_DELAY_MS;
|
|
147
|
+
const results = [];
|
|
148
|
+
|
|
149
|
+
let resolveFunc, rejectFunc;
|
|
150
|
+
const promise = new Promise((res, rej) => { resolveFunc = res; rejectFunc = rej; });
|
|
151
|
+
|
|
152
|
+
function done(err, data) {
|
|
153
|
+
if (callback) return err ? callback(err) : callback(null, data);
|
|
154
|
+
if (err) rejectFunc(err); else resolveFunc(data);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
for (const msgId of ids) {
|
|
159
|
+
const msgResults = [];
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < reactions.length; i++) {
|
|
162
|
+
const r = reactions[i];
|
|
163
|
+
|
|
164
|
+
if (r !== '' && !VALID_REACTIONS.has(r)) {
|
|
165
|
+
utils.warn("setMessageReaction", `Emoji "${r}" is non-standard but will be attempted`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const res = await sendSingleReaction(defaultFuncs, ctx, r, msgId, threadID, options);
|
|
169
|
+
msgResults.push(res);
|
|
170
|
+
|
|
171
|
+
if (i < reactions.length - 1) {
|
|
172
|
+
await new Promise(rr => setTimeout(rr, delayMs + Math.random() * 100));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
results.push(reactions.length === 1 ? msgResults[0] : msgResults);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const out = ids.length === 1 ? results[0] : results;
|
|
180
|
+
done(null, out);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
utils.error("setMessageReaction", err.message || err);
|
|
183
|
+
done(err);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return promise;
|
|
187
|
+
};
|
|
188
|
+
};
|