fb-messenger-e2ee 0.1.1 → 0.1.2

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 (110) hide show
  1. package/README.md +27 -2
  2. package/dist/index.cjs +8811 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +885 -0
  5. package/dist/index.d.ts +885 -0
  6. package/dist/index.js +8843 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +19 -4
  9. package/bun.lock +0 -1014
  10. package/examples/basic-usage.ts +0 -52
  11. package/jest.config.ts +0 -18
  12. package/src/config/env.ts +0 -15
  13. package/src/controllers/client.controller.ts +0 -805
  14. package/src/controllers/dgw-handler.ts +0 -171
  15. package/src/controllers/e2ee-handler.ts +0 -832
  16. package/src/controllers/event-mapper.ts +0 -327
  17. package/src/core/client.ts +0 -151
  18. package/src/e2ee/application/e2ee-client.ts +0 -376
  19. package/src/e2ee/application/fanout-planner.ts +0 -79
  20. package/src/e2ee/application/outbound-message-cache.ts +0 -58
  21. package/src/e2ee/application/prekey-maintenance.ts +0 -55
  22. package/src/e2ee/application/retry-manager.ts +0 -156
  23. package/src/e2ee/facebook/facebook-protocol-utils.ts +0 -230
  24. package/src/e2ee/facebook/icdc-payload.ts +0 -31
  25. package/src/e2ee/index.ts +0 -76
  26. package/src/e2ee/internal.ts +0 -27
  27. package/src/e2ee/media/media-crypto.ts +0 -147
  28. package/src/e2ee/media/media-upload.ts +0 -90
  29. package/src/e2ee/message/builders/client-payload.ts +0 -54
  30. package/src/e2ee/message/builders/consumer-application.ts +0 -362
  31. package/src/e2ee/message/builders/message-application.ts +0 -64
  32. package/src/e2ee/message/builders/message-transport.ts +0 -84
  33. package/src/e2ee/message/codecs/protobuf-codecs.ts +0 -101
  34. package/src/e2ee/message/constants.ts +0 -4
  35. package/src/e2ee/message/message-builder.ts +0 -15
  36. package/src/e2ee/signal/prekey-manager.ts +0 -140
  37. package/src/e2ee/signal/signal-manager.ts +0 -365
  38. package/src/e2ee/store/device-json.ts +0 -47
  39. package/src/e2ee/store/device-repository.ts +0 -12
  40. package/src/e2ee/store/device-store.ts +0 -538
  41. package/src/e2ee/transport/binary/decoder.ts +0 -180
  42. package/src/e2ee/transport/binary/encoder.ts +0 -143
  43. package/src/e2ee/transport/binary/stanzas.ts +0 -97
  44. package/src/e2ee/transport/binary/tokens.ts +0 -64
  45. package/src/e2ee/transport/binary/wa-binary.ts +0 -8
  46. package/src/e2ee/transport/dgw/dgw-socket.ts +0 -345
  47. package/src/e2ee/transport/noise/noise-handshake.ts +0 -417
  48. package/src/e2ee/transport/noise/noise-socket.ts +0 -230
  49. package/src/index.ts +0 -34
  50. package/src/models/client.ts +0 -26
  51. package/src/models/config.ts +0 -14
  52. package/src/models/domain.ts +0 -299
  53. package/src/models/e2ee.ts +0 -295
  54. package/src/models/media.ts +0 -41
  55. package/src/models/messaging.ts +0 -88
  56. package/src/models/thread.ts +0 -69
  57. package/src/repositories/session.repository.ts +0 -20
  58. package/src/services/auth.service.ts +0 -55
  59. package/src/services/e2ee.service.ts +0 -174
  60. package/src/services/facebook-gateway.service.ts +0 -245
  61. package/src/services/icdc.service.ts +0 -177
  62. package/src/services/media.service.ts +0 -304
  63. package/src/services/messaging.service.ts +0 -28
  64. package/src/services/thread.service.ts +0 -199
  65. package/src/types/advanced-types.ts +0 -61
  66. package/src/types/fca-unofficial.d.ts +0 -212
  67. package/src/types/protocol-types.ts +0 -43
  68. package/src/utils/fca-utils.ts +0 -30
  69. package/src/utils/logger.ts +0 -24
  70. package/src/utils/mime.ts +0 -51
  71. package/tests/.env.example +0 -87
  72. package/tests/data/1x1.png +0 -0
  73. package/tests/data/example.txt +0 -1
  74. package/tests/data/file_example.mp3 +0 -0
  75. package/tests/data/file_example.mp4 +0 -0
  76. package/tests/integration.test.ts +0 -498
  77. package/tests/script/echo-e2ee.ts +0 -174
  78. package/tests/script/send-e2ee-media.ts +0 -227
  79. package/tests/script/send-e2ee-reaction.ts +0 -108
  80. package/tests/script/send-e2ee-unsend.ts +0 -115
  81. package/tests/script/send-typing.ts +0 -107
  82. package/tests/script/test-group-send.ts +0 -105
  83. package/tests/setup.ts +0 -3
  84. package/tests/types.test.ts +0 -57
  85. package/tests/unit/controllers/dgw-handler.test.ts +0 -60
  86. package/tests/unit/controllers/e2ee-handler.test.ts +0 -293
  87. package/tests/unit/controllers/event-mapper.test.ts +0 -252
  88. package/tests/unit/e2ee/application-helpers.test.ts +0 -418
  89. package/tests/unit/e2ee/device-store.test.ts +0 -126
  90. package/tests/unit/e2ee/facebook-protocol-utils.test.ts +0 -134
  91. package/tests/unit/e2ee/media-crypto.test.ts +0 -55
  92. package/tests/unit/e2ee/media-upload.test.ts +0 -60
  93. package/tests/unit/e2ee/message-builder.test.ts +0 -42
  94. package/tests/unit/e2ee/message-builders.test.ts +0 -230
  95. package/tests/unit/e2ee/noise-handshake.test.ts +0 -260
  96. package/tests/unit/e2ee/prekey-manager.test.ts +0 -55
  97. package/tests/unit/e2ee/wa-binary.test.ts +0 -127
  98. package/tests/unit/services/e2ee.service.test.ts +0 -101
  99. package/tests/unit/services/facebook-gateway.service.test.ts +0 -138
  100. package/tests/unit/services/media.service.test.ts +0 -169
  101. package/tests/unit/utils/fca-utils.test.ts +0 -48
  102. package/tsconfig.json +0 -19
  103. /package/{src/e2ee/message/proto → proto}/ArmadilloApplication.proto +0 -0
  104. /package/{src/e2ee/message/proto → proto}/ArmadilloICDC.proto +0 -0
  105. /package/{src/e2ee/message/proto → proto}/ConsumerApplication.proto +0 -0
  106. /package/{src/e2ee/message/proto → proto}/MessageApplication.proto +0 -0
  107. /package/{src/e2ee/message/proto → proto}/MessageTransport.proto +0 -0
  108. /package/{src/e2ee/message/proto → proto}/WACommon.proto +0 -0
  109. /package/{src/e2ee/message/proto → proto}/WAMediaTransport.proto +0 -0
  110. /package/{src/e2ee/message/proto → proto}/proto-writer.ts +0 -0
@@ -1,498 +0,0 @@
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
- });
@@ -1,174 +0,0 @@
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
- });