fb-messenger-e2ee 0.1.0 → 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 +34 -6
  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,55 +0,0 @@
1
- import { DeviceStore } from "../../../src/e2ee/store/device-store.ts";
2
- import { generatePreKeys, generateSignedPreKey, buildPreKeyUploadPayload } from "../../../src/e2ee/signal/prekey-manager.ts";
3
- import * as path from "node:path";
4
- import * as os from "node:os";
5
- import * as fs from "node:fs";
6
-
7
- describe("prekey-manager", () => {
8
- let store: DeviceStore;
9
- let testPath: string;
10
-
11
- beforeEach(async () => {
12
- testPath = path.join(os.tmpdir(), `prekey-${Math.random().toString(36).slice(2)}.json`);
13
- store = await DeviceStore.fromFile(testPath);
14
- });
15
-
16
- afterEach(() => {
17
- if (fs.existsSync(testPath)) {
18
- fs.unlinkSync(testPath);
19
- }
20
- });
21
-
22
- it("should generate a batch of prekeys", async () => {
23
- const count = 5;
24
- const keys = await generatePreKeys(store, count);
25
- expect(keys.length).toBe(count);
26
- expect(keys[0]).toBeDefined();
27
- expect(keys[0]!.id).toBeDefined();
28
- expect(keys[0]!.record).toBeDefined();
29
-
30
- // Check if saved in store
31
- const record = await store.getPreKey(keys[0]!.id);
32
- expect(record).toBeDefined();
33
- });
34
-
35
- it("should generate a signed prekey", async () => {
36
- const record = await generateSignedPreKey(store);
37
- expect(record.id()).toBeDefined();
38
- expect(record.signature()).toBeDefined();
39
-
40
- // Check if saved in store
41
- const saved = await store.getSignedPreKey(record.id());
42
- expect(saved).toBeDefined();
43
- });
44
-
45
- it("should build an upload payload", async () => {
46
- await generateSignedPreKey(store);
47
- const preKeys = await generatePreKeys(store, 2);
48
- const payload = await buildPreKeyUploadPayload(store, preKeys);
49
-
50
- expect(payload.registrationId).toBe(store.registrationId);
51
- expect(payload.identityKey.length).toBe(33);
52
- expect(payload.signedPreKey.keyId).toBeDefined();
53
- expect(payload.preKeys.length).toBe(2);
54
- });
55
- });
@@ -1,127 +0,0 @@
1
- import { BinaryDecoder, unmarshal, type Node, BinaryToken, encodePresenceAvailable, encodeKeepAlive, encodeSetPassive, encodePrimingNode, encodeIQ, encodePreKeyUpload, buildUnifiedSessionId, encodeNode, marshal } from "../../../src/e2ee/transport/binary/wa-binary.ts";
2
-
3
- describe("wa-binary", () => {
4
- describe("unmarshal", () => {
5
- it("should throw error on empty data", () => {
6
- expect(() => unmarshal(Buffer.alloc(0))).toThrow("Empty data in unmarshal");
7
- });
8
-
9
- it("should unmarshal a simple node", () => {
10
- // 0 = uncompressed, 248 1 = list of size 1, 3 = "s.whatsapp.net" token
11
- const data = Buffer.from([0, 248, 1, 3]);
12
- const node = unmarshal(data);
13
- expect(node.tag).toBe("s.whatsapp.net");
14
- expect(node.attrs).toEqual({});
15
- });
16
- });
17
-
18
- describe("BinaryDecoder", () => {
19
- it("should read dictionary tokens correctly", () => {
20
- const data = Buffer.from([BinaryToken.Dictionary0, 1]);
21
- const decoder = new BinaryDecoder(data);
22
- // This depends on what's in Dictionary0, but we can at least check it doesn't throw
23
- expect(() => decoder.readString(decoder.readByte())).not.toThrow();
24
- });
25
-
26
- it("should read JID pairs correctly", () => {
27
- // JIDPair token, then user string, then server string
28
- const data = Buffer.from([250, 252, 4, 117, 115, 101, 114, 3]); // user="user", server="s.whatsapp.net"
29
- const decoder = new BinaryDecoder(data);
30
- expect(decoder.read(true)).toBe("user@s.whatsapp.net");
31
- });
32
-
33
- it("should read FBJID correctly", () => {
34
- // FBJID token, user, device(u16), server
35
- const data = Buffer.from([246, 252, 4, 117, 115, 101, 114, 0, 10, 3]); // user="user", device=10, server="s.whatsapp.net"
36
- const decoder = new BinaryDecoder(data);
37
- expect(decoder.read(true)).toBe("user.10@s.whatsapp.net");
38
- });
39
-
40
- it("should read packed8 nibbles correctly", () => {
41
- // Nibble8 token, len byte (MSB=0 means even), packed nibbles
42
- const data = Buffer.from([255, 2, 0x12, 0x34]); // "1234"
43
- const decoder = new BinaryDecoder(data);
44
- expect(decoder.read(true)).toBe("1234");
45
- });
46
- });
47
-
48
- describe("stanza encoders", () => {
49
- it("round-trips presence, keepalive, and passive stanzas", () => {
50
- expect(unmarshal(encodePresenceAvailable("false"))).toMatchObject({
51
- tag: "presence",
52
- attrs: { type: "available", passive: "false" },
53
- });
54
-
55
- expect(unmarshal(encodeKeepAlive("ka-1"))).toMatchObject({
56
- tag: "iq",
57
- attrs: { id: "ka-1", type: "get", xmlns: "w:p" },
58
- });
59
-
60
- const passive = unmarshal(encodeSetPassive("passive-1", false));
61
- expect(passive).toMatchObject({ tag: "iq", attrs: { id: "passive-1", type: "set", xmlns: "passive" } });
62
- expect((passive.content as Node[])[0]!.tag).toBe("active");
63
- });
64
-
65
- it("builds deterministic unified session IDs within the weekly bucket", () => {
66
- const id = buildUnifiedSessionId(10_000, 2_000);
67
- expect(id).toBe(String((10_000 + 2_000 + 3 * 24 * 60 * 60 * 1000) % (7 * 24 * 60 * 60 * 1000)));
68
- });
69
-
70
-
71
-
72
- it("round-trips priming and generic IQ stanzas", () => {
73
- const priming = unmarshal(encodePrimingNode("session-1"));
74
- expect(priming.tag).toBe("ib");
75
- expect(priming.attrs).toEqual({});
76
- expect(priming.content).toEqual([
77
- { tag: "unified_session", attrs: { id: "session-1" }, content: undefined },
78
- { tag: "offline", attrs: {}, content: undefined },
79
- { tag: "dirty", attrs: { type: "account_sync" }, content: undefined },
80
- ]);
81
-
82
- const iq = unmarshal(encodeIQ({ id: "iq-1", to: "s.whatsapp.net", type: "get", xmlns: "encrypt" }, [
83
- encodeNode("count", {}, undefined),
84
- ]));
85
- expect(iq).toMatchObject({ tag: "iq", attrs: { id: "iq-1", type: "get", xmlns: "encrypt" } });
86
- expect((iq.content as Node[])[0]).toMatchObject({ tag: "count", attrs: {} });
87
- });
88
-
89
- it("encodes prekey upload IQ with registration, identity, signed prekey, and one-time prekeys", () => {
90
- const encoded = encodePreKeyUpload(0x01020304, Buffer.alloc(32, 1), {
91
- id: 0x0a0b0c,
92
- pubKey: Buffer.alloc(32, 2),
93
- signature: Buffer.alloc(64, 3),
94
- }, [
95
- { id: 1, pubKey: Buffer.alloc(32, 4) },
96
- { id: 2, pubKey: Buffer.alloc(32, 5) },
97
- ]);
98
-
99
- const iq = unmarshal(encoded);
100
- const children = iq.content as Node[];
101
- const registration = children.find(n => n.tag === "registration")!;
102
- const type = children.find(n => n.tag === "type")!;
103
- const identity = children.find(n => n.tag === "identity")!;
104
- const list = children.find(n => n.tag === "list")!;
105
- const skey = children.find(n => n.tag === "skey")!;
106
-
107
- expect(iq).toMatchObject({ tag: "iq", attrs: { to: "s.whatsapp.net", type: "set", xmlns: "encrypt" } });
108
- expect(registration.content).toEqual(Buffer.from([1, 2, 3, 4]));
109
- expect(type.content).toEqual(Buffer.from([0x05]));
110
- expect(identity.content).toEqual(Buffer.alloc(32, 1));
111
- expect((list.content as Node[])).toHaveLength(2);
112
- expect(((list.content as Node[])[0]!.content as Node[]).find(n => n.tag === "id")!.content).toEqual(Buffer.from([0, 0, 1]));
113
- expect((skey.content as Node[]).find(n => n.tag === "id")!.content).toEqual(Buffer.from([0x0a, 0x0b, 0x0c]));
114
- expect((skey.content as Node[]).find(n => n.tag === "signature")!.content).toEqual(Buffer.alloc(64, 3));
115
- });
116
-
117
- it("round-trips encoded nodes with Messenger JID attributes", () => {
118
- const node = unmarshal(marshal(encodeNode("message", { to: "100.160@msgr", participant: "200:5@msgr", id: "m1" }, Buffer.from("body"))));
119
-
120
- expect(node.tag).toBe("message");
121
- expect(node.attrs.to).toBe("100.160@msgr");
122
- expect(node.attrs.participant).toBe("200.5@msgr");
123
- expect(node.content).toEqual(Buffer.from("body"));
124
- });
125
- });
126
-
127
- });
@@ -1,101 +0,0 @@
1
- import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
- import { E2EEService } from "../../../src/services/e2ee.service.ts";
3
- import { decryptMedia, encryptMedia } from "../../../src/e2ee/media/media-crypto.ts";
4
- import type { MediaUploadConfig } from "../../../src/models/media.ts";
5
-
6
- const uploadConfig: MediaUploadConfig = { host: "upload.example", auth: "auth-token" };
7
-
8
- const createProvider = () => ({
9
- encryptAndUploadMedia: jest.fn<(config: MediaUploadConfig, data: Buffer, type: string, mimeType: string) => Promise<unknown>>()
10
- .mockResolvedValue({ directPath: "/m" }),
11
- decryptMedia: jest.fn((opts: Parameters<typeof decryptMedia>[0]) => decryptMedia(opts)),
12
- });
13
-
14
- describe("E2EEService", () => {
15
- const originalFetch = globalThis.fetch;
16
- let service: E2EEService;
17
- let provider: ReturnType<typeof createProvider>;
18
-
19
- beforeEach(() => {
20
- service = new E2EEService();
21
- provider = createProvider();
22
- });
23
-
24
- afterEach(() => {
25
- globalThis.fetch = originalFetch;
26
- jest.restoreAllMocks();
27
- });
28
-
29
- it("guards access until a provider is connected", () => {
30
- expect(service.isConnected).toBe(false);
31
- expect(() => service.ensureEnabled()).toThrow("E2EE provider not connected");
32
-
33
- service.markConnected();
34
- expect(() => service.getClient()).toThrow("E2EE provider not connected");
35
- });
36
-
37
- it("stores the provider and exposes connection state", () => {
38
- service.setProvider(provider as any, uploadConfig);
39
-
40
- expect(service.isConnected).toBe(true);
41
- expect(service.getClient()).toBe(provider);
42
-
43
- service.markDisconnected();
44
- expect(service.isConnected).toBe(false);
45
- expect(() => service.ensureEnabled()).toThrow("E2EE provider not connected");
46
- });
47
-
48
- it.each([
49
- ["sendImage", "image", "image/jpeg", { data: Buffer.from("image") }],
50
- ["sendVideo", "video", "video/mp4", { data: Buffer.from("video") }],
51
- ["sendAudio", "audio", "audio/ogg; codecs=opus", { data: Buffer.from("audio") }],
52
- ["sendDocument", "document", "application/octet-stream", { data: Buffer.from("doc") }],
53
- ["sendSticker", "image", "image/webp", { data: Buffer.from("sticker") }],
54
- ] as const)("%s encrypts and uploads media with the expected defaults", async (method, type, mimeType, opts) => {
55
- service.setProvider(provider as any, uploadConfig);
56
-
57
- const result = await (service[method] as any)(opts);
58
-
59
- expect(provider.encryptAndUploadMedia).toHaveBeenCalledWith(uploadConfig, opts.data, type, mimeType);
60
- expect(result.messageId).toBe("mock-id");
61
- expect(typeof result.timestampMs).toBe("number");
62
- });
63
-
64
- it("downloads and decrypts media using the selected media type", async () => {
65
- const plaintext = Buffer.from("voice payload");
66
- const encrypted = encryptMedia(plaintext, "audio");
67
- globalThis.fetch = jest.fn(() => Promise.resolve({
68
- ok: true,
69
- arrayBuffer: () => Promise.resolve(encrypted.dataToUpload.buffer.slice(
70
- encrypted.dataToUpload.byteOffset,
71
- encrypted.dataToUpload.byteOffset + encrypted.dataToUpload.byteLength,
72
- )),
73
- })) as any;
74
- service.setProvider(provider as any, uploadConfig);
75
-
76
- const result = await service.downloadMedia({
77
- directPath: "https://cdn.example/media",
78
- mediaKey: encrypted.mediaKey.toString("base64"),
79
- mediaSha256: encrypted.fileSHA256.toString("base64"),
80
- mediaEncSha256: encrypted.fileEncSHA256.toString("base64"),
81
- mediaType: "voice",
82
- mimeType: "audio/ogg",
83
- });
84
-
85
- expect(globalThis.fetch).toHaveBeenCalledWith("https://cdn.example/media");
86
- expect(provider.decryptMedia).toHaveBeenCalledWith(expect.objectContaining({ type: "audio" }));
87
- expect(result).toEqual({ data: plaintext, mimeType: "audio/ogg", fileSize: plaintext.length });
88
- });
89
-
90
- it("throws when media download fails", async () => {
91
- globalThis.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 403 })) as any;
92
- service.setProvider(provider as any, uploadConfig);
93
-
94
- await expect(service.downloadMedia({
95
- directPath: "https://cdn.example/forbidden",
96
- mediaKey: Buffer.alloc(32).toString("base64"),
97
- mediaSha256: Buffer.alloc(32).toString("base64"),
98
- mediaType: "document",
99
- })).rejects.toThrow("Failed to fetch media from CDN: 403");
100
- });
101
- });
@@ -1,138 +0,0 @@
1
- import { describe, it, expect, jest, beforeEach } from "@jest/globals";
2
- import { FacebookGatewayService } from "../../../src/services/facebook-gateway.service.ts";
3
-
4
- describe("FacebookGatewayService", () => {
5
- let service: FacebookGatewayService;
6
-
7
- beforeEach(() => {
8
- service = new FacebookGatewayService();
9
- });
10
-
11
- it("configures FCA listening options", () => {
12
- const api: any = { setOptions: jest.fn() };
13
-
14
- service.configure(api);
15
-
16
- expect(api.setOptions).toHaveBeenCalledWith(expect.objectContaining({
17
- selfListen: false,
18
- listenEvents: true,
19
- autoMarkRead: false,
20
- autoMarkDelivery: false,
21
- online: true,
22
- }));
23
- });
24
-
25
- it("starts MQTT listening and normalizes callback errors", async () => {
26
- const event = { type: "message", body: "hi" };
27
- const api: any = {
28
- listenMqtt: jest.fn((callback: (err: unknown, event?: any) => void) => {
29
- callback(null, event);
30
- callback("boom");
31
- }),
32
- };
33
- const onEvent = jest.fn();
34
- const onError = jest.fn();
35
-
36
- await service.startListening(api, onEvent, onError);
37
-
38
- expect(onEvent).toHaveBeenCalledWith(event);
39
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "boom" }));
40
- });
41
-
42
- it("sends text, attachments, stickers, reactions, typing, read, and unsend through FCA", async () => {
43
- const api: any = {
44
- sendMessage: jest.fn().mockReturnValue({ messageID: "mid" }),
45
- setMessageReaction: jest.fn(),
46
- unsendMessage: jest.fn(),
47
- sendTypingIndicator: jest.fn(),
48
- markAsRead: jest.fn(),
49
- };
50
-
51
- await expect(service.sendMessage(api, "thread", "hello", "reply")).resolves.toEqual({ messageID: "mid" });
52
- await expect(service.sendAttachmentMessage(api, {
53
- threadId: "thread",
54
- data: Buffer.from("abc"),
55
- fileName: "a.txt",
56
- caption: "cap",
57
- replyToMessageId: "reply",
58
- })).resolves.toEqual({ messageID: "mid" });
59
- await expect(service.sendStickerMessage(api, { threadId: "thread", stickerId: 123, replyToMessageId: "reply" })).resolves.toEqual({ messageID: "mid" });
60
- await service.sendReaction(api, "mid", "👍");
61
- await service.unsendMessage(api, "mid");
62
- await service.sendTyping(api, "thread", true);
63
- await service.markAsRead(api, "thread");
64
-
65
- expect(api.sendMessage).toHaveBeenNthCalledWith(1, "hello", "thread", undefined, "reply");
66
- const attachmentPayload = api.sendMessage.mock.calls[1][0];
67
- expect(attachmentPayload.body).toBe("cap");
68
- expect(attachmentPayload.attachment.path).toBe("a.txt");
69
- expect(api.sendMessage).toHaveBeenNthCalledWith(3, { sticker: 123 }, "thread", undefined, "reply");
70
- expect(api.setMessageReaction).toHaveBeenCalledWith("👍", "mid", undefined, true);
71
- expect(api.unsendMessage).toHaveBeenCalledWith("mid");
72
- expect(api.sendTypingIndicator).toHaveBeenCalledWith(true, "thread");
73
- expect(api.markAsRead).toHaveBeenCalledWith("thread", true);
74
- });
75
-
76
- it("throws clear errors when optional FCA methods are missing", async () => {
77
- await expect(service.sendReaction({} as any, "mid", "👍")).rejects.toThrow("setMessageReaction is not available");
78
- await expect(service.unsendMessage({} as any, "mid")).rejects.toThrow("unsendMessage is not available");
79
- await expect(service.sendTyping({} as any, "thread", true)).rejects.toThrow("sendTypingIndicator is not available");
80
- await expect(service.markAsRead({} as any, "thread")).rejects.toThrow("markAsRead is not available");
81
- });
82
-
83
- it("stops MQTT listening when available", () => {
84
- const api: any = { stopListenMqtt: jest.fn() };
85
- service.stop(api);
86
- expect(api.stopListenMqtt).toHaveBeenCalled();
87
- });
88
-
89
- it("fetches CAT from GraphQL responses and rejects malformed responses", async () => {
90
- const api: any = {
91
- fb_dtsg: "token",
92
- getCurrentUserID: jest.fn(() => "1001"),
93
- httpPost: jest.fn().mockReturnValue('for (;;); {"data":{"secure_message_over_wa_cat_query":{"encrypted_serialized_cat":"cat-token"}}}'),
94
- };
95
-
96
- await expect(service.fetchCAT(api)).resolves.toBe("cat-token");
97
- expect(api.httpPost).toHaveBeenCalledWith("https://www.facebook.com/api/graphql/", expect.objectContaining({
98
- fb_dtsg: "token",
99
- __user: "1001",
100
- doc_id: "23999698219677129",
101
- }));
102
-
103
- const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
104
- try {
105
- await expect(service.fetchCAT({ ...api, httpPost: jest.fn().mockReturnValue("not-json") })).rejects.toThrow("Failed to parse CAT response");
106
- await expect(service.fetchCAT({ ...api, httpPost: jest.fn().mockReturnValue('{"data":{}}') })).rejects.toThrow("Failed to extract CAT token");
107
- } finally {
108
- errorSpy.mockRestore();
109
- }
110
- });
111
-
112
- it("fetches and registers ICDC payloads through httpPost", async () => {
113
- const api: any = {
114
- getAppState: jest.fn(() => [{ key: "c_user", value: "1001" }]),
115
- httpPost: jest.fn()
116
- .mockReturnValueOnce('{"ok":true}')
117
- .mockReturnValueOnce('{"registered":true}'),
118
- };
119
-
120
- await expect(service.fetchICDC(api, "1001", "42", Buffer.from("cat"))).resolves.toEqual({ ok: true });
121
- expect(api.httpPost).toHaveBeenNthCalledWith(1, "https://reg-e2ee.facebook.com/v2/fb_icdc_fetch", expect.objectContaining({
122
- fbid: "1001",
123
- fb_cat: "cat",
124
- device_id: "42",
125
- }));
126
-
127
- await expect(service.registerICDC(api, "1001", "42", Buffer.from("cat"), { payload: "x" })).resolves.toEqual({ registered: true });
128
- expect(api.httpPost).toHaveBeenNthCalledWith(2, "https://reg-e2ee.facebook.com/v2/fb_register_v2", expect.objectContaining({
129
- fbid: "1001",
130
- fb_cat: Buffer.from("cat").toString("base64"),
131
- device_id: "42",
132
- payload: "x",
133
- }));
134
-
135
- await expect(service.fetchICDC({} as any, "1", "2", Buffer.from("cat"))).rejects.toThrow("api.httpPost is required");
136
- await expect(service.registerICDC({} as any, "1", "2", Buffer.from("cat"), {})).rejects.toThrow("api.httpPost is required");
137
- });
138
- });
@@ -1,169 +0,0 @@
1
- import { describe, it, expect, jest, afterEach } from "@jest/globals";
2
- import * as http from "node:http";
3
- import { MediaService } from "../../../src/services/media.service.ts";
4
-
5
- describe("MediaService", () => {
6
- const gateway = {
7
- sendAttachmentMessage: jest.fn<(api: any, input: any) => Promise<Record<string, unknown>>>(),
8
- sendStickerMessage: jest.fn<(api: any, input: any) => Promise<Record<string, unknown>>>(),
9
- };
10
- const service = new MediaService(gateway as any);
11
- const api: any = {};
12
-
13
- afterEach(() => {
14
- jest.clearAllMocks();
15
- });
16
-
17
- it.each(["sendImage", "sendVideo", "sendAudio", "sendFile"] as const)(
18
- "%s delegates attachment send to the gateway",
19
- async (method) => {
20
- gateway.sendAttachmentMessage.mockResolvedValue({ messageID: "mid.1" });
21
-
22
- const result = await service[method](api, {
23
- threadId: "thread-1",
24
- data: Buffer.from("file"),
25
- fileName: "file.bin",
26
- caption: "caption",
27
- replyToMessageId: "reply-1",
28
- } as any);
29
-
30
- expect(result).toEqual({ messageID: "mid.1" });
31
- expect(gateway.sendAttachmentMessage).toHaveBeenCalledWith(api, {
32
- threadId: "thread-1",
33
- data: Buffer.from("file"),
34
- fileName: "file.bin",
35
- caption: "caption",
36
- replyToMessageId: "reply-1",
37
- });
38
- },
39
- );
40
-
41
- it("sendSticker delegates to the gateway", async () => {
42
- gateway.sendStickerMessage.mockResolvedValue({ messageID: "mid.sticker" });
43
-
44
- await expect(service.sendSticker(api, {
45
- threadId: "thread-1",
46
- stickerId: "123",
47
- replyToMessageId: "reply-1",
48
- } as any)).resolves.toEqual({ messageID: "mid.sticker" });
49
-
50
- expect(gateway.sendStickerMessage).toHaveBeenCalledWith(api, {
51
- threadId: "thread-1",
52
- stickerId: "123",
53
- replyToMessageId: "reply-1",
54
- });
55
- });
56
-
57
- it("normalizes supported attachment shapes", () => {
58
- expect(service.normalizeAttachment({
59
- type: "image",
60
- url: "https://cdn/image",
61
- filename: "a.png",
62
- mimeType: "image/png",
63
- fileSize: "10",
64
- mediaKey: "mk",
65
- mediaSha256: "sha",
66
- mediaEncSha256: "encsha",
67
- directPath: "/mms/a",
68
- width: "640",
69
- height: 480,
70
- thumbnailUrl: "https://cdn/thumb",
71
- })).toMatchObject({
72
- type: "image",
73
- url: "https://cdn/image",
74
- fileName: "a.png",
75
- mimeType: "image/png",
76
- fileSize: 10,
77
- mediaKey: "mk",
78
- mediaSha256: "sha",
79
- mediaEncSha256: "encsha",
80
- directPath: "/mms/a",
81
- width: 640,
82
- height: 480,
83
- previewUrl: "https://cdn/thumb",
84
- });
85
-
86
- expect(service.normalizeAttachment({ type: "video", durationMs: "42", width: 1, height: 2 })).toMatchObject({
87
- type: "video",
88
- duration: 42,
89
- width: 1,
90
- height: 2,
91
- });
92
- expect(service.normalizeAttachment({ type: "voice", duration: "7" })).toMatchObject({ type: "voice", duration: 7 });
93
- expect(service.normalizeAttachment({ type: "sticker", stickerID: "99" })).toMatchObject({ type: "sticker", stickerID: 99 });
94
- expect(service.normalizeAttachment({ type: "location", latitude: "10.5", longitude: "20.5" })).toMatchObject({
95
- type: "location",
96
- latitude: 10.5,
97
- longitude: 20.5,
98
- });
99
- expect(service.normalizeAttachment({ type: "link", description: "desc", source: "src", previewUrl: "p" })).toMatchObject({
100
- type: "link",
101
- description: "desc",
102
- sourceText: "src",
103
- previewUrl: "p",
104
- });
105
- expect(service.normalizeAttachment({ type: "unknown", name: "x.bin" })).toMatchObject({ type: "file", fileName: "x.bin" });
106
- expect(service.normalizeAttachment(null)).toBeNull();
107
- expect(service.normalizeAttachment({})).toBeNull();
108
- });
109
-
110
- it("downloads media bytes over HTTP", async () => {
111
- const server = http.createServer((_req, res) => res.end(Buffer.from("downloaded")));
112
- await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
113
- const address = server.address();
114
- if (!address || typeof address === "string") throw new Error("missing test server address");
115
-
116
- try {
117
- await expect(service.downloadMedia({ url: `http://127.0.0.1:${address.port}/file` })).resolves.toEqual(Buffer.from("downloaded"));
118
- } finally {
119
- await new Promise<void>(resolve => server.close(() => resolve()));
120
- }
121
- });
122
-
123
- it("delegates thread management APIs and reports unavailable FCA methods", async () => {
124
- const fullApi: any = {
125
- muteThread: jest.fn(),
126
- setTitle: jest.fn(),
127
- changeGroupImage: jest.fn(),
128
- deleteThread: jest.fn(),
129
- };
130
-
131
- await service.muteThread(fullApi, { threadId: "t", muteSeconds: 60 });
132
- await service.renameThread(fullApi, { threadId: "t", newName: "new" });
133
- await service.setGroupPhoto(fullApi, { threadId: "t", data: Buffer.from("img"), mimeType: "image/png" });
134
- await service.deleteThread(fullApi, { threadId: "t" });
135
-
136
- expect(fullApi.muteThread).toHaveBeenCalledWith("t", 60);
137
- expect(fullApi.setTitle).toHaveBeenCalledWith("new", "t");
138
- expect(fullApi.changeGroupImage).toHaveBeenCalledWith(Buffer.from("img"), "t");
139
- expect(fullApi.deleteThread).toHaveBeenCalledWith("t");
140
- await expect(service.muteThread({} as any, { threadId: "t", muteSeconds: 1 })).rejects.toThrow("muteThread not available");
141
- });
142
-
143
- it("maps FCA search and user info responses", async () => {
144
- const searchApi: any = {
145
- searchUsers: jest.fn().mockReturnValue([{ "1": { name: "Alice", firstName: "A", vanity: "alice", thumbSrc: "pic", gender: 2 } }]),
146
- getUserInfo: jest.fn().mockReturnValue({ "1": { name: "Alice", firstName: "A", vanity: "alice", thumbSrc: "pic", gender: 2 } }),
147
- };
148
-
149
- await expect(service.searchUsers(searchApi, { query: "ali" })).resolves.toEqual([{
150
- id: "1",
151
- name: "Alice",
152
- firstName: "A",
153
- username: "alice",
154
- profilePictureUrl: "pic",
155
- gender: 2,
156
- }]);
157
- await expect(service.getUserInfo(searchApi, { userId: "1" })).resolves.toMatchObject({ id: "1", name: "Alice" });
158
- await expect(service.searchUsers({ searchUsers: jest.fn().mockReturnValue(null) } as any, { query: "x" })).resolves.toEqual([]);
159
- await expect(service.getUserInfo({ getUserInfo: jest.fn().mockReturnValue({}) } as any, { userId: "2" })).resolves.toBeNull();
160
- });
161
-
162
- it("creates a thread through FCA when available and falls back to the user ID", async () => {
163
- const createApi: any = { createNewGroup: jest.fn().mockReturnValue({ threadID: "thread-new" }) };
164
-
165
- await expect(service.createThread(createApi, { userId: "1001" })).resolves.toMatchObject({ id: "thread-new", type: 1 });
166
- expect(createApi.createNewGroup).toHaveBeenCalledWith(["1001"], "", undefined);
167
- await expect(service.createThread({} as any, { userId: "1001" })).resolves.toMatchObject({ id: "1001", type: 1 });
168
- });
169
- });
@@ -1,48 +0,0 @@
1
- import { str, num, date } from "../../../src/utils/fca-utils.ts";
2
-
3
- describe("fca-utils", () => {
4
- describe("str", () => {
5
- it("should return string as is", () => {
6
- expect(str("hello")).toBe("hello");
7
- });
8
- it("should convert number to string", () => {
9
- expect(str(123)).toBe("123");
10
- });
11
- it("should convert boolean to string", () => {
12
- expect(str(true)).toBe("true");
13
- });
14
- it("should return empty string for null/undefined", () => {
15
- expect(str(null)).toBe("");
16
- expect(str(undefined)).toBe("");
17
- });
18
- });
19
-
20
- describe("num", () => {
21
- it("should return number as is", () => {
22
- expect(num(123)).toBe(123);
23
- });
24
- it("should convert string to number", () => {
25
- expect(num("123")).toBe(123);
26
- });
27
- it("should return 0 for invalid numbers", () => {
28
- expect(num("abc")).toBe(0);
29
- expect(num(null)).toBe(0);
30
- });
31
- });
32
-
33
- describe("date", () => {
34
- it("should return timestamp from Date object", () => {
35
- const d = new Date();
36
- expect(date(d)).toBe(d.getTime());
37
- });
38
- it("should parse date string", () => {
39
- const s = "2023-01-01T00:00:00Z";
40
- expect(date(s)).toBe(new Date(s).getTime());
41
- });
42
- it("should return current time for invalid input", () => {
43
- const now = Date.now();
44
- const result = date("invalid");
45
- expect(result).toBeGreaterThanOrEqual(now);
46
- });
47
- });
48
- });
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "lib": ["ESNext"],
4
- "target": "ESNext",
5
- "module": "ESNext",
6
- "moduleResolution": "bundler",
7
- "allowImportingTsExtensions": true,
8
- "verbatimModuleSyntax": true,
9
- "noEmit": true,
10
- "strict": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "skipLibCheck": true,
13
- "noFallthroughCasesInSwitch": true,
14
- "noUncheckedIndexedAccess": true,
15
- "noImplicitOverride": true,
16
- "types": ["bun", "jest"]
17
- },
18
- "include": ["src/**/*.ts", "src/**/*.d.ts", "tests/**/*.ts"]
19
- }