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.
- package/DOCS.md +41 -3
- package/README.md +27 -2
- package/dist/index.cjs +9017 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +910 -0
- package/dist/index.d.ts +910 -0
- package/dist/index.js +9049 -0
- package/dist/index.js.map +1 -0
- package/package.json +19 -4
- package/bun.lock +0 -1014
- package/examples/basic-usage.ts +0 -52
- package/jest.config.ts +0 -18
- package/src/config/env.ts +0 -15
- package/src/controllers/client.controller.ts +0 -805
- package/src/controllers/dgw-handler.ts +0 -171
- package/src/controllers/e2ee-handler.ts +0 -832
- package/src/controllers/event-mapper.ts +0 -327
- package/src/core/client.ts +0 -151
- package/src/e2ee/application/e2ee-client.ts +0 -376
- package/src/e2ee/application/fanout-planner.ts +0 -79
- package/src/e2ee/application/outbound-message-cache.ts +0 -58
- package/src/e2ee/application/prekey-maintenance.ts +0 -55
- package/src/e2ee/application/retry-manager.ts +0 -156
- package/src/e2ee/facebook/facebook-protocol-utils.ts +0 -230
- package/src/e2ee/facebook/icdc-payload.ts +0 -31
- package/src/e2ee/index.ts +0 -76
- package/src/e2ee/internal.ts +0 -27
- package/src/e2ee/media/media-crypto.ts +0 -147
- package/src/e2ee/media/media-upload.ts +0 -90
- package/src/e2ee/message/builders/client-payload.ts +0 -54
- package/src/e2ee/message/builders/consumer-application.ts +0 -362
- package/src/e2ee/message/builders/message-application.ts +0 -64
- package/src/e2ee/message/builders/message-transport.ts +0 -84
- package/src/e2ee/message/codecs/protobuf-codecs.ts +0 -101
- package/src/e2ee/message/constants.ts +0 -4
- package/src/e2ee/message/message-builder.ts +0 -15
- package/src/e2ee/signal/prekey-manager.ts +0 -140
- package/src/e2ee/signal/signal-manager.ts +0 -365
- package/src/e2ee/store/device-json.ts +0 -47
- package/src/e2ee/store/device-repository.ts +0 -12
- package/src/e2ee/store/device-store.ts +0 -538
- package/src/e2ee/transport/binary/decoder.ts +0 -180
- package/src/e2ee/transport/binary/encoder.ts +0 -143
- package/src/e2ee/transport/binary/stanzas.ts +0 -97
- package/src/e2ee/transport/binary/tokens.ts +0 -64
- package/src/e2ee/transport/binary/wa-binary.ts +0 -8
- package/src/e2ee/transport/dgw/dgw-socket.ts +0 -345
- package/src/e2ee/transport/noise/noise-handshake.ts +0 -417
- package/src/e2ee/transport/noise/noise-socket.ts +0 -230
- package/src/index.ts +0 -34
- package/src/models/client.ts +0 -26
- package/src/models/config.ts +0 -14
- package/src/models/domain.ts +0 -299
- package/src/models/e2ee.ts +0 -295
- package/src/models/media.ts +0 -41
- package/src/models/messaging.ts +0 -88
- package/src/models/thread.ts +0 -69
- package/src/repositories/session.repository.ts +0 -20
- package/src/services/auth.service.ts +0 -55
- package/src/services/e2ee.service.ts +0 -174
- package/src/services/facebook-gateway.service.ts +0 -245
- package/src/services/icdc.service.ts +0 -177
- package/src/services/media.service.ts +0 -304
- package/src/services/messaging.service.ts +0 -28
- package/src/services/thread.service.ts +0 -199
- package/src/types/advanced-types.ts +0 -61
- package/src/types/fca-unofficial.d.ts +0 -212
- package/src/types/protocol-types.ts +0 -43
- package/src/utils/fca-utils.ts +0 -30
- package/src/utils/logger.ts +0 -24
- package/src/utils/mime.ts +0 -51
- package/tests/.env.example +0 -87
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +0 -1
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +0 -498
- package/tests/script/echo-e2ee.ts +0 -174
- package/tests/script/send-e2ee-media.ts +0 -227
- package/tests/script/send-e2ee-reaction.ts +0 -108
- package/tests/script/send-e2ee-unsend.ts +0 -115
- package/tests/script/send-typing.ts +0 -107
- package/tests/script/test-group-send.ts +0 -105
- package/tests/setup.ts +0 -3
- package/tests/types.test.ts +0 -57
- package/tests/unit/controllers/dgw-handler.test.ts +0 -60
- package/tests/unit/controllers/e2ee-handler.test.ts +0 -293
- package/tests/unit/controllers/event-mapper.test.ts +0 -252
- package/tests/unit/e2ee/application-helpers.test.ts +0 -418
- package/tests/unit/e2ee/device-store.test.ts +0 -126
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +0 -134
- package/tests/unit/e2ee/media-crypto.test.ts +0 -55
- package/tests/unit/e2ee/media-upload.test.ts +0 -60
- package/tests/unit/e2ee/message-builder.test.ts +0 -42
- package/tests/unit/e2ee/message-builders.test.ts +0 -230
- package/tests/unit/e2ee/noise-handshake.test.ts +0 -260
- package/tests/unit/e2ee/prekey-manager.test.ts +0 -55
- package/tests/unit/e2ee/wa-binary.test.ts +0 -127
- package/tests/unit/services/e2ee.service.test.ts +0 -101
- package/tests/unit/services/facebook-gateway.service.test.ts +0 -138
- package/tests/unit/services/media.service.test.ts +0 -169
- package/tests/unit/utils/fca-utils.test.ts +0 -48
- package/tsconfig.json +0 -19
- /package/{src/e2ee/message/proto → proto}/ArmadilloApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/ArmadilloICDC.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/ConsumerApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/MessageApplication.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/MessageTransport.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/WACommon.proto +0 -0
- /package/{src/e2ee/message/proto → proto}/WAMediaTransport.proto +0 -0
- /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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|