fb-messenger-e2ee 0.1.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/DOCS.md +310 -0
- package/LICENSE +660 -0
- package/README.md +153 -0
- package/bun.lock +1014 -0
- package/examples/basic-usage.ts +52 -0
- package/jest.config.ts +18 -0
- package/package.json +56 -0
- package/src/config/env.ts +15 -0
- package/src/controllers/client.controller.ts +805 -0
- package/src/controllers/dgw-handler.ts +171 -0
- package/src/controllers/e2ee-handler.ts +832 -0
- package/src/controllers/event-mapper.ts +327 -0
- package/src/core/client.ts +151 -0
- package/src/e2ee/application/e2ee-client.ts +376 -0
- package/src/e2ee/application/fanout-planner.ts +79 -0
- package/src/e2ee/application/outbound-message-cache.ts +58 -0
- package/src/e2ee/application/prekey-maintenance.ts +55 -0
- package/src/e2ee/application/retry-manager.ts +156 -0
- package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
- package/src/e2ee/facebook/icdc-payload.ts +31 -0
- package/src/e2ee/index.ts +76 -0
- package/src/e2ee/internal.ts +27 -0
- package/src/e2ee/media/media-crypto.ts +147 -0
- package/src/e2ee/media/media-upload.ts +90 -0
- package/src/e2ee/message/builders/client-payload.ts +54 -0
- package/src/e2ee/message/builders/consumer-application.ts +362 -0
- package/src/e2ee/message/builders/message-application.ts +64 -0
- package/src/e2ee/message/builders/message-transport.ts +84 -0
- package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
- package/src/e2ee/message/constants.ts +4 -0
- package/src/e2ee/message/message-builder.ts +15 -0
- package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
- package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
- package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
- package/src/e2ee/message/proto/MessageApplication.proto +82 -0
- package/src/e2ee/message/proto/MessageTransport.proto +77 -0
- package/src/e2ee/message/proto/WACommon.proto +66 -0
- package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
- package/src/e2ee/message/proto/proto-writer.ts +76 -0
- package/src/e2ee/signal/prekey-manager.ts +140 -0
- package/src/e2ee/signal/signal-manager.ts +365 -0
- package/src/e2ee/store/device-json.ts +47 -0
- package/src/e2ee/store/device-repository.ts +12 -0
- package/src/e2ee/store/device-store.ts +538 -0
- package/src/e2ee/transport/binary/decoder.ts +180 -0
- package/src/e2ee/transport/binary/encoder.ts +143 -0
- package/src/e2ee/transport/binary/stanzas.ts +97 -0
- package/src/e2ee/transport/binary/tokens.ts +64 -0
- package/src/e2ee/transport/binary/wa-binary.ts +8 -0
- package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
- package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
- package/src/e2ee/transport/noise/noise-socket.ts +230 -0
- package/src/index.ts +34 -0
- package/src/models/client.ts +26 -0
- package/src/models/config.ts +14 -0
- package/src/models/domain.ts +299 -0
- package/src/models/e2ee.ts +295 -0
- package/src/models/media.ts +41 -0
- package/src/models/messaging.ts +88 -0
- package/src/models/thread.ts +69 -0
- package/src/repositories/session.repository.ts +20 -0
- package/src/services/auth.service.ts +55 -0
- package/src/services/e2ee.service.ts +174 -0
- package/src/services/facebook-gateway.service.ts +245 -0
- package/src/services/icdc.service.ts +177 -0
- package/src/services/media.service.ts +304 -0
- package/src/services/messaging.service.ts +28 -0
- package/src/services/thread.service.ts +199 -0
- package/src/types/advanced-types.ts +61 -0
- package/src/types/fca-unofficial.d.ts +212 -0
- package/src/types/protocol-types.ts +43 -0
- package/src/utils/fca-utils.ts +30 -0
- package/src/utils/logger.ts +24 -0
- package/src/utils/mime.ts +51 -0
- package/tests/.env.example +87 -0
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +1 -0
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +498 -0
- package/tests/script/echo-e2ee.ts +174 -0
- package/tests/script/send-e2ee-media.ts +227 -0
- package/tests/script/send-e2ee-reaction.ts +108 -0
- package/tests/script/send-e2ee-unsend.ts +115 -0
- package/tests/script/send-typing.ts +107 -0
- package/tests/script/test-group-send.ts +105 -0
- package/tests/setup.ts +3 -0
- package/tests/types.test.ts +57 -0
- package/tests/unit/controllers/dgw-handler.test.ts +60 -0
- package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
- package/tests/unit/controllers/event-mapper.test.ts +252 -0
- package/tests/unit/e2ee/application-helpers.test.ts +418 -0
- package/tests/unit/e2ee/device-store.test.ts +126 -0
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
- package/tests/unit/e2ee/media-crypto.test.ts +55 -0
- package/tests/unit/e2ee/media-upload.test.ts +60 -0
- package/tests/unit/e2ee/message-builder.test.ts +42 -0
- package/tests/unit/e2ee/message-builders.test.ts +230 -0
- package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
- package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
- package/tests/unit/e2ee/wa-binary.test.ts +127 -0
- package/tests/unit/services/e2ee.service.test.ts +101 -0
- package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
- package/tests/unit/services/media.service.test.ts +169 -0
- package/tests/unit/utils/fca-utils.test.ts +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for fbE2EE - runs with Bun test runner (bun test).
|
|
3
|
+
*
|
|
4
|
+
* These tests use in-memory fixture data and do NOT hit Facebook's servers.
|
|
5
|
+
* They verify that:
|
|
6
|
+
* - domain mapping (attachment normalisation, mention mapping, event mapping) is correct
|
|
7
|
+
* - service-level methods behave correctly given a stubbed FCAApi
|
|
8
|
+
* - ClientController correctly wires services and emits typed events
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { expect, mock, test, describe, beforeEach } from "bun:test";
|
|
13
|
+
|
|
14
|
+
// ---- Source under test -------------------------------------------------------
|
|
15
|
+
import type { FCAApi } from "fca-unofficial";
|
|
16
|
+
import { ClientController } from "../src/controllers/client.controller.ts";
|
|
17
|
+
import type { MessengerEvent, MessengerMessage } from "../src/models/domain.ts";
|
|
18
|
+
import { AuthService } from "../src/services/auth.service.ts";
|
|
19
|
+
import { E2EEService } from "../src/services/e2ee.service.ts";
|
|
20
|
+
import { FacebookGatewayService } from "../src/services/facebook-gateway.service.ts";
|
|
21
|
+
import { MediaService } from "../src/services/media.service.ts";
|
|
22
|
+
import { MessagingService } from "../src/services/messaging.service.ts";
|
|
23
|
+
import { ThreadService } from "../src/services/thread.service.ts";
|
|
24
|
+
import { ICDCService } from "../src/services/icdc.service.ts";
|
|
25
|
+
|
|
26
|
+
// ---- Fixture factories -------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeFakeApi(overrides: Partial<FCAApi> = {}): FCAApi {
|
|
29
|
+
return {
|
|
30
|
+
getCurrentUserID: () => "123456",
|
|
31
|
+
getAppState: () => [],
|
|
32
|
+
setOptions: () => undefined,
|
|
33
|
+
listenMqtt: async () => undefined,
|
|
34
|
+
sendMessage: async () => ({ messageID: "mid.001", timestamp: 1_700_000_000_000 }),
|
|
35
|
+
setMessageReaction: async () => undefined,
|
|
36
|
+
unsendMessage: async () => undefined,
|
|
37
|
+
sendTypingIndicator: async () => undefined,
|
|
38
|
+
markAsRead: async () => undefined,
|
|
39
|
+
stopListenMqtt: () => undefined,
|
|
40
|
+
muteThread: async () => undefined,
|
|
41
|
+
setTitle: async () => undefined,
|
|
42
|
+
deleteThread: async () => undefined,
|
|
43
|
+
getThreadList: async () => [],
|
|
44
|
+
getThreadHistory: async () => [],
|
|
45
|
+
createPoll: async () => undefined,
|
|
46
|
+
editMessage: async () => ({ messageID: "mid.001", body: "edited" }),
|
|
47
|
+
addUserToGroup: async () => undefined,
|
|
48
|
+
removeUserFromGroup: async () => undefined,
|
|
49
|
+
changeAdminStatus: async () => undefined,
|
|
50
|
+
getUserInfo: async () => ({
|
|
51
|
+
"123456": { name: "Test User", firstName: "Test", vanity: "testuser", thumbSrc: "", gender: 1 },
|
|
52
|
+
}),
|
|
53
|
+
searchUsers: async () => [] as Record<string, import("fca-unofficial").FriendInfo>[],
|
|
54
|
+
createNewGroup: async () => ({ threadID: "t.001" }),
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeServices() {
|
|
60
|
+
const gateway = new FacebookGatewayService();
|
|
61
|
+
const mediaService = new MediaService(gateway);
|
|
62
|
+
const messagingService = new MessagingService(gateway);
|
|
63
|
+
const threadService = new ThreadService(mediaService);
|
|
64
|
+
const authService = new AuthService({ readSession: async () => null, saveSession: async () => undefined } as unknown as ConstructorParameters<typeof AuthService>[0]);
|
|
65
|
+
const e2eeService = new E2EEService();
|
|
66
|
+
const icdcService = new ICDCService("test-agent");
|
|
67
|
+
const eventBus = new EventEmitter();
|
|
68
|
+
const controller = new ClientController(
|
|
69
|
+
authService,
|
|
70
|
+
gateway,
|
|
71
|
+
mediaService,
|
|
72
|
+
e2eeService,
|
|
73
|
+
icdcService,
|
|
74
|
+
eventBus,
|
|
75
|
+
);
|
|
76
|
+
return { gateway, mediaService, messagingService, threadService, e2eeService, eventBus, controller };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// MediaService - attachment normalisation
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
describe("MediaService.normalizeAttachment", () => {
|
|
84
|
+
const { mediaService } = makeServices();
|
|
85
|
+
|
|
86
|
+
test("returns null for non-object", () => {
|
|
87
|
+
expect(mediaService.normalizeAttachment(null)).toBeNull();
|
|
88
|
+
expect(mediaService.normalizeAttachment("bad")).toBeNull();
|
|
89
|
+
expect(mediaService.normalizeAttachment(42)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns null when type is missing", () => {
|
|
93
|
+
expect(mediaService.normalizeAttachment({})).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("maps image attachment", () => {
|
|
97
|
+
const raw = {
|
|
98
|
+
type: "photo",
|
|
99
|
+
url: "https://cdn.fb.com/img.jpg",
|
|
100
|
+
previewUrl: "https://cdn.fb.com/thumb.jpg",
|
|
101
|
+
width: 1920,
|
|
102
|
+
height: 1080,
|
|
103
|
+
mimeType: "image/jpeg",
|
|
104
|
+
fileSize: 204800,
|
|
105
|
+
};
|
|
106
|
+
const att = mediaService.normalizeAttachment(raw);
|
|
107
|
+
expect(att).not.toBeNull();
|
|
108
|
+
if (att?.type !== "photo") throw new Error("Expected photo attachment");
|
|
109
|
+
expect(att.url).toBe("https://cdn.fb.com/img.jpg");
|
|
110
|
+
expect(att.width).toBe(1920);
|
|
111
|
+
expect(att.height).toBe(1080);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("maps audio attachment with duration", () => {
|
|
115
|
+
const raw = {
|
|
116
|
+
type: "audio",
|
|
117
|
+
url: "https://cdn.fb.com/audio.mp3",
|
|
118
|
+
mimeType: "audio/mpeg",
|
|
119
|
+
duration: 30,
|
|
120
|
+
fileSize: 512000,
|
|
121
|
+
};
|
|
122
|
+
const att = mediaService.normalizeAttachment(raw);
|
|
123
|
+
if (att?.type !== "audio") throw new Error("Expected audio attachment");
|
|
124
|
+
expect(att.duration).toBe(30);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("maps sticker attachment", () => {
|
|
128
|
+
const raw = { type: "sticker", url: "https://cdn.fb.com/sticker.png", stickerID: 369239263222822 };
|
|
129
|
+
const att = mediaService.normalizeAttachment(raw);
|
|
130
|
+
expect(att!.type).toBe("sticker");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("maps location attachment", () => {
|
|
134
|
+
const raw = { type: "location", latitude: 10.123, longitude: 106.456 };
|
|
135
|
+
const att = mediaService.normalizeAttachment(raw);
|
|
136
|
+
if (att?.type !== "location") throw new Error("Expected location attachment");
|
|
137
|
+
expect(att.latitude).toBe(10.123);
|
|
138
|
+
expect(att.longitude).toBe(106.456);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// =============================================================================
|
|
143
|
+
// MediaService - downloadMedia
|
|
144
|
+
// =============================================================================
|
|
145
|
+
|
|
146
|
+
describe("MediaService.downloadMedia", () => {
|
|
147
|
+
const { mediaService } = makeServices();
|
|
148
|
+
|
|
149
|
+
test("throws on unreachable URL without crashing process", async () => {
|
|
150
|
+
// Point at localhost:1 - guaranteed to refuse connection quickly
|
|
151
|
+
await expect(
|
|
152
|
+
mediaService.downloadMedia({ url: "http://127.0.0.1:1/file.bin" }),
|
|
153
|
+
).rejects.toThrow();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// MessagingService
|
|
159
|
+
// =============================================================================
|
|
160
|
+
|
|
161
|
+
describe("MessagingService.sendText", () => {
|
|
162
|
+
const { messagingService } = makeServices();
|
|
163
|
+
|
|
164
|
+
test("calls api.sendMessage and returns result", async () => {
|
|
165
|
+
const api = makeFakeApi();
|
|
166
|
+
const result = await messagingService.sendText(api, {
|
|
167
|
+
threadId: "t.001",
|
|
168
|
+
text: "Hello",
|
|
169
|
+
});
|
|
170
|
+
expect(result).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("passes replyToMessageId through", async () => {
|
|
174
|
+
let capturedReply: string | undefined;
|
|
175
|
+
const api = makeFakeApi({
|
|
176
|
+
sendMessage: (async (_msg: unknown, _tid: string, _cb: unknown, replyTo?: string) => {
|
|
177
|
+
capturedReply = replyTo;
|
|
178
|
+
return { messageID: "mid.002" };
|
|
179
|
+
}) as FCAApi["sendMessage"],
|
|
180
|
+
});
|
|
181
|
+
await messagingService.sendText(api, {
|
|
182
|
+
threadId: "t.001",
|
|
183
|
+
text: "Reply",
|
|
184
|
+
replyToMessageId: "mid.000",
|
|
185
|
+
});
|
|
186
|
+
expect(capturedReply).toBe("mid.000");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// ThreadService
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
describe("ThreadService.getThreadList", () => {
|
|
195
|
+
const { threadService } = makeServices();
|
|
196
|
+
|
|
197
|
+
test("returns empty array when fca returns empty", async () => {
|
|
198
|
+
const api = makeFakeApi({ getThreadList: async () => [] });
|
|
199
|
+
const result = await threadService.getThreadList(api, { limit: 10 });
|
|
200
|
+
expect(result).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("throws when getThreadList not available", async () => {
|
|
204
|
+
const api = makeFakeApi({ getThreadList: undefined });
|
|
205
|
+
await expect(
|
|
206
|
+
threadService.getThreadList(api, { limit: 10 }),
|
|
207
|
+
).rejects.toThrow("getThreadList not available");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("ThreadService.createPoll", () => {
|
|
212
|
+
test("calls createPoll on api", async () => {
|
|
213
|
+
const { threadService } = makeServices();
|
|
214
|
+
let called = false;
|
|
215
|
+
const api = makeFakeApi({
|
|
216
|
+
createPoll: async () => {
|
|
217
|
+
called = true;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
await threadService.createPoll(api, {
|
|
221
|
+
threadId: "t.001",
|
|
222
|
+
title: "Favourite fruit?",
|
|
223
|
+
options: { Apple: false, Mango: true },
|
|
224
|
+
});
|
|
225
|
+
expect(called).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("throws when createPoll not available", async () => {
|
|
229
|
+
const { threadService } = makeServices();
|
|
230
|
+
const api = makeFakeApi({ createPoll: undefined });
|
|
231
|
+
await expect(
|
|
232
|
+
threadService.createPoll(api, { threadId: "t.001", title: "Poll" }),
|
|
233
|
+
).rejects.toThrow("createPoll not available");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("ThreadService.editMessage", () => {
|
|
238
|
+
test("returns edited message info", async () => {
|
|
239
|
+
const { threadService } = makeServices();
|
|
240
|
+
const api = makeFakeApi({
|
|
241
|
+
editMessage: async () => ({ messageID: "mid.999", body: "new text" }),
|
|
242
|
+
});
|
|
243
|
+
const result = await threadService.editMessage(api, {
|
|
244
|
+
messageId: "mid.999",
|
|
245
|
+
newText: "new text",
|
|
246
|
+
});
|
|
247
|
+
expect(result.messageId).toBe("mid.999");
|
|
248
|
+
expect(result.newText).toBe("new text");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("ThreadService.forwardAttachment", () => {
|
|
253
|
+
test("calls forwardAttachment on api", async () => {
|
|
254
|
+
const { threadService } = makeServices();
|
|
255
|
+
let capturedAttId: string | undefined;
|
|
256
|
+
let capturedTargets: string[] | undefined;
|
|
257
|
+
const api = makeFakeApi({
|
|
258
|
+
forwardAttachment: async (attachmentID: string, targets: string | string[]) => {
|
|
259
|
+
capturedAttId = attachmentID;
|
|
260
|
+
capturedTargets = Array.isArray(targets) ? targets : [targets];
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
await threadService.forwardAttachment(api, {
|
|
264
|
+
attachmentId: "att.001",
|
|
265
|
+
threadIds: ["t.001", "t.002"],
|
|
266
|
+
});
|
|
267
|
+
expect(capturedAttId).toBe("att.001");
|
|
268
|
+
expect(capturedTargets).toEqual(["t.001", "t.002"]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// =============================================================================
|
|
273
|
+
// E2EEService - contract/stub
|
|
274
|
+
// =============================================================================
|
|
275
|
+
|
|
276
|
+
describe("E2EEService stubs", () => {
|
|
277
|
+
test("isConnected starts false", () => {
|
|
278
|
+
const svc = new E2EEService();
|
|
279
|
+
expect(svc.isConnected).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("markConnected/Disconnected toggles state", () => {
|
|
283
|
+
const svc = new E2EEService();
|
|
284
|
+
svc.markConnected();
|
|
285
|
+
expect(svc.isConnected).toBe(true);
|
|
286
|
+
svc.markDisconnected();
|
|
287
|
+
expect(svc.isConnected).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("sendImage throws", async () => {
|
|
291
|
+
const svc = new E2EEService();
|
|
292
|
+
await expect(
|
|
293
|
+
svc.sendImage({ chatJid: "x@s.whatsapp.net", data: Buffer.alloc(1) }),
|
|
294
|
+
).rejects.toThrow("E2EE provider not connected");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("downloadMedia throws", async () => {
|
|
298
|
+
const svc = new E2EEService();
|
|
299
|
+
await expect(
|
|
300
|
+
svc.downloadMedia({
|
|
301
|
+
directPath: "/path",
|
|
302
|
+
mediaKey: "key",
|
|
303
|
+
mediaSha256: "sha",
|
|
304
|
+
mediaType: "image",
|
|
305
|
+
}),
|
|
306
|
+
).rejects.toThrow("E2EE provider not connected");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// =============================================================================
|
|
311
|
+
// ClientController - event mapping
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
describe("ClientController event mapping", () => {
|
|
315
|
+
function collectEvents(controller: ClientController, eventBus: EventEmitter): MessengerEvent[] {
|
|
316
|
+
const events: MessengerEvent[] = [];
|
|
317
|
+
eventBus.on("event", (e: MessengerEvent) => events.push(e));
|
|
318
|
+
|
|
319
|
+
// Expose internal emitMappedEvent via the test harness:
|
|
320
|
+
// We invoke connect() with a mocked API that captures the listener callback,
|
|
321
|
+
// then call it directly.
|
|
322
|
+
return events;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Helper to directly fire a raw event through the controller's private mapper.
|
|
326
|
+
function fireRaw(controller: ClientController, raw: Record<string, unknown>): void {
|
|
327
|
+
const testController = controller as unknown as {
|
|
328
|
+
eventMapper: { emitMappedEvent(rawEvent: Record<string, unknown>): void };
|
|
329
|
+
};
|
|
330
|
+
testController.eventMapper.emitMappedEvent(raw);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test("maps standard message event", () => {
|
|
334
|
+
const { controller, eventBus } = makeServices();
|
|
335
|
+
const events = collectEvents(controller, eventBus);
|
|
336
|
+
fireRaw(controller, {
|
|
337
|
+
type: "message",
|
|
338
|
+
messageID: "mid.1",
|
|
339
|
+
threadID: "t.1",
|
|
340
|
+
senderID: "u.1",
|
|
341
|
+
body: "Hello world",
|
|
342
|
+
timestamp: 1_700_000_000_000,
|
|
343
|
+
});
|
|
344
|
+
expect(events).toHaveLength(1);
|
|
345
|
+
const evt = events[0]!;
|
|
346
|
+
expect(evt.type).toBe("message");
|
|
347
|
+
const msg = (evt as { type: "message"; data: MessengerMessage }).data;
|
|
348
|
+
expect(msg.id).toBe("mid.1");
|
|
349
|
+
expect(msg.text).toBe("Hello world");
|
|
350
|
+
expect(msg.threadId).toBe("t.1");
|
|
351
|
+
expect(msg.senderId).toBe("u.1");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("maps reaction event", () => {
|
|
355
|
+
const { controller, eventBus } = makeServices();
|
|
356
|
+
const events = collectEvents(controller, eventBus);
|
|
357
|
+
fireRaw(controller, {
|
|
358
|
+
type: "message_reaction",
|
|
359
|
+
messageID: "mid.2",
|
|
360
|
+
threadID: "t.1",
|
|
361
|
+
userID: "u.2",
|
|
362
|
+
reaction: "❤️",
|
|
363
|
+
timestamp: 1_700_000_001_000,
|
|
364
|
+
});
|
|
365
|
+
expect(events[0]!.type).toBe("reaction");
|
|
366
|
+
const d = (events[0] as Extract<MessengerEvent, { type: "reaction" }>).data;
|
|
367
|
+
expect(d.reaction).toBe("❤️");
|
|
368
|
+
expect(d.actorId).toBe("u.2");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("maps typing event", () => {
|
|
372
|
+
const { controller, eventBus } = makeServices();
|
|
373
|
+
const events = collectEvents(controller, eventBus);
|
|
374
|
+
fireRaw(controller, {
|
|
375
|
+
type: "typ",
|
|
376
|
+
threadID: "t.1",
|
|
377
|
+
from: "u.3",
|
|
378
|
+
isTyping: true,
|
|
379
|
+
});
|
|
380
|
+
expect(events[0]!.type).toBe("typing");
|
|
381
|
+
const d = (events[0] as Extract<MessengerEvent, { type: "typing" }>).data;
|
|
382
|
+
expect(d.isTyping).toBe(true);
|
|
383
|
+
expect(d.senderId).toBe("u.3");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("maps unsend event", () => {
|
|
387
|
+
const { controller, eventBus } = makeServices();
|
|
388
|
+
const events = collectEvents(controller, eventBus);
|
|
389
|
+
fireRaw(controller, {
|
|
390
|
+
type: "message_unsend",
|
|
391
|
+
messageID: "mid.3",
|
|
392
|
+
threadID: "t.1",
|
|
393
|
+
senderID: "u.1",
|
|
394
|
+
timestamp: 1_700_000_002_000,
|
|
395
|
+
});
|
|
396
|
+
expect(events[0]!.type).toBe("message_unsend");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("maps read_receipt event", () => {
|
|
400
|
+
const { controller, eventBus } = makeServices();
|
|
401
|
+
const events = collectEvents(controller, eventBus);
|
|
402
|
+
fireRaw(controller, {
|
|
403
|
+
type: "read_receipt",
|
|
404
|
+
threadID: "t.1",
|
|
405
|
+
reader: "u.4",
|
|
406
|
+
time: 1_700_000_003_000,
|
|
407
|
+
});
|
|
408
|
+
expect(events[0]!.type).toBe("read_receipt");
|
|
409
|
+
const d = (events[0] as Extract<MessengerEvent, { type: "read_receipt" }>).data;
|
|
410
|
+
expect(d.readerId).toBe("u.4");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("maps presence event", () => {
|
|
414
|
+
const { controller, eventBus } = makeServices();
|
|
415
|
+
const events = collectEvents(controller, eventBus);
|
|
416
|
+
fireRaw(controller, {
|
|
417
|
+
type: "presence",
|
|
418
|
+
userID: "u.5",
|
|
419
|
+
userStatus: 1,
|
|
420
|
+
timestamp: 1_700_000_004_000,
|
|
421
|
+
});
|
|
422
|
+
expect(events[0]!.type).toBe("presence");
|
|
423
|
+
const d = (events[0] as Extract<MessengerEvent, { type: "presence" }>).data;
|
|
424
|
+
expect(d.userId).toBe("u.5");
|
|
425
|
+
expect(d.isOnline).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("maps unknown event to raw", () => {
|
|
429
|
+
const { controller, eventBus } = makeServices();
|
|
430
|
+
const events = collectEvents(controller, eventBus);
|
|
431
|
+
fireRaw(controller, { type: "some_unknown_type", foo: "bar" });
|
|
432
|
+
expect(events[0]!.type).toBe("raw");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("maps message with attachments", () => {
|
|
436
|
+
const { controller, eventBus } = makeServices();
|
|
437
|
+
const events = collectEvents(controller, eventBus);
|
|
438
|
+
fireRaw(controller, {
|
|
439
|
+
type: "message",
|
|
440
|
+
messageID: "mid.10",
|
|
441
|
+
threadID: "t.1",
|
|
442
|
+
senderID: "u.1",
|
|
443
|
+
body: "",
|
|
444
|
+
timestamp: 1_700_000_005_000,
|
|
445
|
+
attachments: [
|
|
446
|
+
{ type: "photo", url: "https://cdn.fb.com/img.jpg", width: 800, height: 600 },
|
|
447
|
+
],
|
|
448
|
+
});
|
|
449
|
+
const msg = (events[0] as Extract<MessengerEvent, { type: "message" }>).data;
|
|
450
|
+
expect(msg.attachments).toHaveLength(1);
|
|
451
|
+
expect(msg.attachments![0]!.type).toBe("photo");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("maps message with replyTo", () => {
|
|
455
|
+
const { controller, eventBus } = makeServices();
|
|
456
|
+
const events = collectEvents(controller, eventBus);
|
|
457
|
+
fireRaw(controller, {
|
|
458
|
+
type: "message",
|
|
459
|
+
messageID: "mid.11",
|
|
460
|
+
threadID: "t.1",
|
|
461
|
+
senderID: "u.1",
|
|
462
|
+
body: "yes",
|
|
463
|
+
timestamp: 1_700_000_006_000,
|
|
464
|
+
messageReply: { messageID: "mid.old", senderID: "u.2", body: "original" },
|
|
465
|
+
});
|
|
466
|
+
const msg = (events[0] as Extract<MessengerEvent, { type: "message" }>).data;
|
|
467
|
+
expect(msg.replyTo?.messageId).toBe("mid.old");
|
|
468
|
+
expect(msg.replyTo?.senderId).toBe("u.2");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("maps e2ee_connected event and marks e2ee service", () => {
|
|
472
|
+
const { controller, eventBus, e2eeService } = makeServices();
|
|
473
|
+
const events = collectEvents(controller, eventBus);
|
|
474
|
+
fireRaw(controller, { type: "e2ee_connected" });
|
|
475
|
+
expect(events[0]!.type).toBe("e2ee_connected");
|
|
476
|
+
expect(e2eeService.isConnected).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// =============================================================================
|
|
481
|
+
// ClientController - requireApi guard
|
|
482
|
+
// =============================================================================
|
|
483
|
+
|
|
484
|
+
describe("ClientController E2EE-only guard", () => {
|
|
485
|
+
test("sendMessage rejects non-E2EE thread IDs", async () => {
|
|
486
|
+
const { controller } = makeServices();
|
|
487
|
+
await expect(
|
|
488
|
+
controller.sendMessage({ threadId: "t.1", text: "hi" }),
|
|
489
|
+
).rejects.toThrow("sendMessage is E2EE-only");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("sendMessage requires connectE2EE before E2EE sends", async () => {
|
|
493
|
+
const { controller } = makeServices();
|
|
494
|
+
await expect(
|
|
495
|
+
controller.sendMessage({ threadId: "1001.0@msgr", text: "hi" }),
|
|
496
|
+
).rejects.toThrow("sendMessage requires an active E2EE connection");
|
|
497
|
+
});
|
|
498
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { FBClient } from "../../src/index.ts";
|
|
4
|
+
import type { E2EEMessage, MessengerEvent } from "../../src/models/domain.ts";
|
|
5
|
+
|
|
6
|
+
const APPSTATE_PATH = join(process.cwd(), "tests/appstate.json");
|
|
7
|
+
const SESSION_PATH = join(process.cwd(), "tests/session.json");
|
|
8
|
+
const DEVICE_PATH = join(process.cwd(), "tests/device.json");
|
|
9
|
+
const ENV_PATH = join(process.cwd(), "tests/.env");
|
|
10
|
+
const DEFAULT_ECHO_CACHE_TTL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
function loadEnvFile(filePath: string): void {
|
|
13
|
+
if (!existsSync(filePath)) return;
|
|
14
|
+
|
|
15
|
+
const raw = readFileSync(filePath, "utf8");
|
|
16
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
19
|
+
|
|
20
|
+
const idx = trimmed.indexOf("=");
|
|
21
|
+
if (idx <= 0) continue;
|
|
22
|
+
|
|
23
|
+
const key = trimmed.slice(0, idx).trim();
|
|
24
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
25
|
+
|
|
26
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
27
|
+
value = value.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
process.env[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseMessengerUserId(value: string): string {
|
|
34
|
+
const userPart = value.split("@")[0] ?? value;
|
|
35
|
+
const dotIdx = userPart.indexOf(".");
|
|
36
|
+
const colonIdx = userPart.indexOf(":");
|
|
37
|
+
const cuts = [dotIdx, colonIdx].filter((idx) => idx >= 0).sort((a, b) => a - b);
|
|
38
|
+
const end = cuts[0] ?? userPart.length;
|
|
39
|
+
return userPart.slice(0, end) || value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function messageKey(msg: E2EEMessage): string {
|
|
43
|
+
return msg.id || `${msg.chatJid}:${msg.senderJid}:${msg.timestampMs}:${msg.text}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function echoSignature(threadId: string, text: string): string {
|
|
47
|
+
return `${threadId}\u0000${text}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isTextEchoable(msg: E2EEMessage): boolean {
|
|
51
|
+
return typeof msg.text === "string" && msg.text.trim().length > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
loadEnvFile(ENV_PATH);
|
|
56
|
+
|
|
57
|
+
if (!existsSync(APPSTATE_PATH)) {
|
|
58
|
+
console.error("echo-e2ee", `Missing appstate file at ${APPSTATE_PATH}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("echo-e2ee", "Initializing FBClient...");
|
|
63
|
+
const client = new FBClient({
|
|
64
|
+
appStatePath: APPSTATE_PATH,
|
|
65
|
+
sessionStorePath: SESSION_PATH,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let selfUserId = "";
|
|
69
|
+
const seenMessageIds = new Set<string>();
|
|
70
|
+
const ownEchoes = new Map<string, number>();
|
|
71
|
+
const echoPrefix = process.env.ECHO_PREFIX ?? "";
|
|
72
|
+
const echoCacheTtlMs = Number(process.env.ECHO_CACHE_TTL_MS ?? String(DEFAULT_ECHO_CACHE_TTL_MS));
|
|
73
|
+
|
|
74
|
+
const pruneOwnEchoes = () => {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
for (const [key, expiresAt] of ownEchoes) {
|
|
77
|
+
if (expiresAt <= now) ownEchoes.delete(key);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
client.onEvent(async (event: MessengerEvent) => {
|
|
82
|
+
try {
|
|
83
|
+
if (event.type === "e2ee_connected") {
|
|
84
|
+
console.log("echo-e2ee", "E2EE connected. Echo is ready.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (event.type === "error") {
|
|
89
|
+
console.error("echo-e2ee", "Client error:", event.data.message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (event.type !== "e2ee_message") return;
|
|
94
|
+
|
|
95
|
+
pruneOwnEchoes();
|
|
96
|
+
|
|
97
|
+
console.log(event);
|
|
98
|
+
const msg = event.data;
|
|
99
|
+
const key = messageKey(msg);
|
|
100
|
+
if (seenMessageIds.has(key)) return;
|
|
101
|
+
seenMessageIds.add(key);
|
|
102
|
+
|
|
103
|
+
if (!isTextEchoable(msg)) {
|
|
104
|
+
console.log("echo-e2ee", `Skip non-text/empty E2EE message ${msg.id || "<no-id>"}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const threadId = msg.chatJid || msg.threadId;
|
|
109
|
+
if (!threadId) {
|
|
110
|
+
console.warn("echo-e2ee", `Skip message ${msg.id || "<no-id>"}: missing thread id`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const senderUserId = parseMessengerUserId(msg.senderId || msg.senderJid);
|
|
115
|
+
const echoText = `${echoPrefix}${msg.text}`;
|
|
116
|
+
const sig = echoSignature(threadId, msg.text);
|
|
117
|
+
|
|
118
|
+
// If our own echo is delivered back to this client, consume it and stop.
|
|
119
|
+
// This allows echoing messages sent from another device of the same account,
|
|
120
|
+
// while avoiding infinite echo loops.
|
|
121
|
+
if (senderUserId === selfUserId && ownEchoes.has(sig)) {
|
|
122
|
+
ownEchoes.delete(sig);
|
|
123
|
+
console.log("echo-e2ee", `Skip own echoed message in ${threadId}: "${msg.text}"`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log("echo-e2ee", `Echo ${msg.id || "<no-id>"} from ${msg.senderJid} to ${threadId}: "${msg.text}"`);
|
|
128
|
+
await client.sendMessage({ threadId, text: echoText });
|
|
129
|
+
ownEchoes.set(echoSignature(threadId, echoText), Date.now() + echoCacheTtlMs);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error("echo-e2ee", "Echo failed:", err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
console.log("echo-e2ee", "Connecting to Messenger...");
|
|
137
|
+
const { userId } = await client.connect();
|
|
138
|
+
selfUserId = parseMessengerUserId(userId);
|
|
139
|
+
console.log("echo-e2ee", `Connected as User ID: ${selfUserId}`);
|
|
140
|
+
|
|
141
|
+
const userDevicePath = join(process.cwd(), `device-${selfUserId}.json`);
|
|
142
|
+
const finalDevicePath = existsSync(userDevicePath) ? userDevicePath : DEVICE_PATH;
|
|
143
|
+
|
|
144
|
+
console.log("echo-e2ee", `Connecting E2EE stream using: ${finalDevicePath}`);
|
|
145
|
+
await client.connectE2EE(finalDevicePath, selfUserId);
|
|
146
|
+
console.log("echo-e2ee", "E2EE stream active. Waiting for messages...");
|
|
147
|
+
|
|
148
|
+
const exitAfterMs = Number(process.env.ECHO_EXIT_AFTER_MS ?? "0");
|
|
149
|
+
if (exitAfterMs > 0) {
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
console.log("echo-e2ee", `Exit after ${exitAfterMs}ms`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}, exitAfterMs);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shutdown = async () => {
|
|
157
|
+
console.log("\necho-e2ee", "Shutting down...");
|
|
158
|
+
await client.disconnect().catch(() => {});
|
|
159
|
+
process.exit(0);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
process.on("SIGINT", shutdown);
|
|
163
|
+
process.on("SIGTERM", shutdown);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error("echo-e2ee", "Startup failed:", err);
|
|
166
|
+
await client.disconnect().catch(() => {});
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main().catch((err) => {
|
|
172
|
+
console.error("echo-e2ee", "Fatal:", err);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|