fb-messenger-e2ee 0.1.1 → 0.1.3

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