fb-messenger-e2ee 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/DOCS.md +310 -0
  2. package/LICENSE +660 -0
  3. package/README.md +153 -0
  4. package/bun.lock +1014 -0
  5. package/examples/basic-usage.ts +52 -0
  6. package/jest.config.ts +18 -0
  7. package/package.json +56 -0
  8. package/src/config/env.ts +15 -0
  9. package/src/controllers/client.controller.ts +805 -0
  10. package/src/controllers/dgw-handler.ts +171 -0
  11. package/src/controllers/e2ee-handler.ts +832 -0
  12. package/src/controllers/event-mapper.ts +327 -0
  13. package/src/core/client.ts +151 -0
  14. package/src/e2ee/application/e2ee-client.ts +376 -0
  15. package/src/e2ee/application/fanout-planner.ts +79 -0
  16. package/src/e2ee/application/outbound-message-cache.ts +58 -0
  17. package/src/e2ee/application/prekey-maintenance.ts +55 -0
  18. package/src/e2ee/application/retry-manager.ts +156 -0
  19. package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
  20. package/src/e2ee/facebook/icdc-payload.ts +31 -0
  21. package/src/e2ee/index.ts +76 -0
  22. package/src/e2ee/internal.ts +27 -0
  23. package/src/e2ee/media/media-crypto.ts +147 -0
  24. package/src/e2ee/media/media-upload.ts +90 -0
  25. package/src/e2ee/message/builders/client-payload.ts +54 -0
  26. package/src/e2ee/message/builders/consumer-application.ts +362 -0
  27. package/src/e2ee/message/builders/message-application.ts +64 -0
  28. package/src/e2ee/message/builders/message-transport.ts +84 -0
  29. package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
  30. package/src/e2ee/message/constants.ts +4 -0
  31. package/src/e2ee/message/message-builder.ts +15 -0
  32. package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
  33. package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
  34. package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
  35. package/src/e2ee/message/proto/MessageApplication.proto +82 -0
  36. package/src/e2ee/message/proto/MessageTransport.proto +77 -0
  37. package/src/e2ee/message/proto/WACommon.proto +66 -0
  38. package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
  39. package/src/e2ee/message/proto/proto-writer.ts +76 -0
  40. package/src/e2ee/signal/prekey-manager.ts +140 -0
  41. package/src/e2ee/signal/signal-manager.ts +365 -0
  42. package/src/e2ee/store/device-json.ts +47 -0
  43. package/src/e2ee/store/device-repository.ts +12 -0
  44. package/src/e2ee/store/device-store.ts +538 -0
  45. package/src/e2ee/transport/binary/decoder.ts +180 -0
  46. package/src/e2ee/transport/binary/encoder.ts +143 -0
  47. package/src/e2ee/transport/binary/stanzas.ts +97 -0
  48. package/src/e2ee/transport/binary/tokens.ts +64 -0
  49. package/src/e2ee/transport/binary/wa-binary.ts +8 -0
  50. package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
  51. package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
  52. package/src/e2ee/transport/noise/noise-socket.ts +230 -0
  53. package/src/index.ts +34 -0
  54. package/src/models/client.ts +26 -0
  55. package/src/models/config.ts +14 -0
  56. package/src/models/domain.ts +299 -0
  57. package/src/models/e2ee.ts +295 -0
  58. package/src/models/media.ts +41 -0
  59. package/src/models/messaging.ts +88 -0
  60. package/src/models/thread.ts +69 -0
  61. package/src/repositories/session.repository.ts +20 -0
  62. package/src/services/auth.service.ts +55 -0
  63. package/src/services/e2ee.service.ts +174 -0
  64. package/src/services/facebook-gateway.service.ts +245 -0
  65. package/src/services/icdc.service.ts +177 -0
  66. package/src/services/media.service.ts +304 -0
  67. package/src/services/messaging.service.ts +28 -0
  68. package/src/services/thread.service.ts +199 -0
  69. package/src/types/advanced-types.ts +61 -0
  70. package/src/types/fca-unofficial.d.ts +212 -0
  71. package/src/types/protocol-types.ts +43 -0
  72. package/src/utils/fca-utils.ts +30 -0
  73. package/src/utils/logger.ts +24 -0
  74. package/src/utils/mime.ts +51 -0
  75. package/tests/.env.example +87 -0
  76. package/tests/data/1x1.png +0 -0
  77. package/tests/data/example.txt +1 -0
  78. package/tests/data/file_example.mp3 +0 -0
  79. package/tests/data/file_example.mp4 +0 -0
  80. package/tests/integration.test.ts +498 -0
  81. package/tests/script/echo-e2ee.ts +174 -0
  82. package/tests/script/send-e2ee-media.ts +227 -0
  83. package/tests/script/send-e2ee-reaction.ts +108 -0
  84. package/tests/script/send-e2ee-unsend.ts +115 -0
  85. package/tests/script/send-typing.ts +107 -0
  86. package/tests/script/test-group-send.ts +105 -0
  87. package/tests/setup.ts +3 -0
  88. package/tests/types.test.ts +57 -0
  89. package/tests/unit/controllers/dgw-handler.test.ts +60 -0
  90. package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
  91. package/tests/unit/controllers/event-mapper.test.ts +252 -0
  92. package/tests/unit/e2ee/application-helpers.test.ts +418 -0
  93. package/tests/unit/e2ee/device-store.test.ts +126 -0
  94. package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
  95. package/tests/unit/e2ee/media-crypto.test.ts +55 -0
  96. package/tests/unit/e2ee/media-upload.test.ts +60 -0
  97. package/tests/unit/e2ee/message-builder.test.ts +42 -0
  98. package/tests/unit/e2ee/message-builders.test.ts +230 -0
  99. package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
  100. package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
  101. package/tests/unit/e2ee/wa-binary.test.ts +127 -0
  102. package/tests/unit/services/e2ee.service.test.ts +101 -0
  103. package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
  104. package/tests/unit/services/media.service.test.ts +169 -0
  105. package/tests/unit/utils/fca-utils.test.ts +48 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,88 @@
1
+ export interface SendMessageInput {
2
+ threadId: string;
3
+ text: string;
4
+ replyToMessageId?: string;
5
+ }
6
+
7
+ export interface SendMediaInput {
8
+ threadId: string;
9
+ data: Buffer;
10
+ fileName: string;
11
+ /** Optional; inferred from fileName extension when omitted. */
12
+ mimeType?: string;
13
+ caption?: string;
14
+ replyToMessageId?: string;
15
+ /** Optional media width in pixels for E2EE image/video/sticker payloads. */
16
+ width?: number;
17
+ /** Optional media height in pixels for E2EE image/video/sticker payloads. */
18
+ height?: number;
19
+ /** Optional media duration in whole seconds for E2EE video/audio payloads. */
20
+ seconds?: number;
21
+ /** Alias for seconds, kept for callers that use media-style naming. */
22
+ duration?: number;
23
+ /** Whether E2EE audio should be sent as push-to-talk/voice. Defaults to true for sendAudio. */
24
+ ptt?: boolean;
25
+ }
26
+
27
+ export interface SendStickerInput {
28
+ threadId: string;
29
+ stickerId: number;
30
+ replyToMessageId?: string;
31
+ }
32
+
33
+ export interface TypingInput {
34
+ threadId: string;
35
+ isTyping: boolean;
36
+ }
37
+
38
+ export interface MarkReadInput {
39
+ threadId: string;
40
+ }
41
+
42
+ export interface SendReactionInput {
43
+ messageId: string;
44
+ reaction: string;
45
+ /** Thread ID or canonical E2EE chat JID containing the target message. */
46
+ threadId: string;
47
+ /** Sender JID of the target E2EE message; required for reacting to someone else's group message. */
48
+ senderJid?: string;
49
+ /** Alias for senderJid for callers that prefer explicit target naming. */
50
+ targetSenderJid?: string;
51
+ }
52
+
53
+ export interface MuteThreadInput {
54
+ threadId: string;
55
+ /** Seconds to mute; -1 = forever, 0 = unmute */
56
+ muteSeconds: number;
57
+ }
58
+
59
+ export interface RenameThreadInput {
60
+ threadId: string;
61
+ newName: string;
62
+ }
63
+
64
+ export interface SetGroupPhotoInput {
65
+ threadId: string;
66
+ data: Buffer;
67
+ mimeType: string;
68
+ }
69
+
70
+ export interface DeleteThreadInput {
71
+ threadId: string;
72
+ }
73
+
74
+ export interface CreateThreadInput {
75
+ userId: string;
76
+ }
77
+
78
+ export interface SearchUsersInput {
79
+ query: string;
80
+ }
81
+
82
+ export interface GetUserInfoInput {
83
+ userId: string;
84
+ }
85
+
86
+ export interface DownloadMediaInput {
87
+ url: string;
88
+ }
@@ -0,0 +1,69 @@
1
+ import type { Thread } from "./domain.ts";
2
+
3
+ export interface GetThreadListInput {
4
+ limit: number;
5
+ /** Pass timestamp from last thread to paginate */
6
+ beforeTimestamp?: number | null;
7
+ /** Thread folder: "" | "INBOX" | "PENDING" | etc. */
8
+ folder?: string;
9
+ }
10
+
11
+ export interface GetThreadHistoryInput {
12
+ threadId: string;
13
+ amount: number;
14
+ /** Fetch messages before this timestamp for pagination */
15
+ beforeTimestamp?: number;
16
+ }
17
+
18
+ export interface ForwardAttachmentInput {
19
+ attachmentId: string;
20
+ /** One or more thread IDs to forward to */
21
+ threadIds: string[];
22
+ }
23
+
24
+ export interface CreatePollInput {
25
+ threadId: string;
26
+ title: string;
27
+ /** Map of option text -> whether creator pre-votes for it */
28
+ options?: Record<string, boolean>;
29
+ }
30
+
31
+ export interface EditMessageInput {
32
+ messageId: string;
33
+ newText: string;
34
+ }
35
+
36
+ export interface AddGroupMemberInput {
37
+ threadId: string;
38
+ /** User ID(s) to add */
39
+ userIds: string[];
40
+ }
41
+
42
+ export interface RemoveGroupMemberInput {
43
+ threadId: string;
44
+ userId: string;
45
+ }
46
+
47
+ export interface ChangeAdminStatusInput {
48
+ threadId: string;
49
+ userId: string;
50
+ isAdmin: boolean;
51
+ }
52
+
53
+ export interface ThreadDetails extends Omit<Thread, "snippet"> {
54
+ unreadCount: number;
55
+ messageCount: number;
56
+ emoji: string | null;
57
+ muteUntil: number | null;
58
+ participantIds: string[];
59
+ adminIds: string[];
60
+ isArchived: boolean;
61
+ folder: string;
62
+ /** fca-unofficial can return null for snippet */
63
+ snippet: string | null | undefined;
64
+ }
65
+
66
+ export interface EditMessageResult {
67
+ messageId: string;
68
+ newText: string;
69
+ }
@@ -0,0 +1,20 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import type { SessionData, SessionRepository } from "../models/client.ts";
5
+
6
+ export class FileSessionRepository implements SessionRepository {
7
+ public async read(path: string): Promise<SessionData | null> {
8
+ try {
9
+ const content = await readFile(path, "utf-8");
10
+ return JSON.parse(content) as SessionData;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ public async write(path: string, session: SessionData): Promise<void> {
17
+ await mkdir(dirname(path), { recursive: true });
18
+ await writeFile(path, JSON.stringify(session, null, 2), "utf-8");
19
+ }
20
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import type { AppStateCookie } from "fca-unofficial";
4
+
5
+ import type { AuthConfig } from "../models/config.ts";
6
+ import type { SessionData, SessionRepository } from "../models/client.ts";
7
+
8
+ export class AuthService {
9
+ public constructor(private readonly sessionRepository: SessionRepository) {}
10
+
11
+ public async readAppState(config: AuthConfig): Promise<AppStateCookie[]> {
12
+ let parsed: unknown;
13
+
14
+ if (config.appState) {
15
+ if (typeof config.appState === "string") {
16
+ parsed = JSON.parse(config.appState);
17
+ } else {
18
+ parsed = config.appState;
19
+ }
20
+ } else if (config.appStatePath) {
21
+ const raw = await readFile(config.appStatePath, "utf-8");
22
+ parsed = JSON.parse(raw);
23
+ } else {
24
+ throw new Error("Either appState or appStatePath must be provided");
25
+ }
26
+
27
+ if (!Array.isArray(parsed)) {
28
+ throw new Error("Invalid appState format: expected an array");
29
+ }
30
+
31
+ return parsed.map(item => {
32
+ const cookie = item as Record<string, unknown>;
33
+ return {
34
+ key: String(cookie.key ?? cookie.name ?? ""),
35
+ value: String(cookie.value ?? ""),
36
+ domain: typeof cookie.domain === "string" ? cookie.domain : ".facebook.com",
37
+ path: typeof cookie.path === "string" ? cookie.path : "/",
38
+ expires:
39
+ typeof cookie.expires === "number" && Number.isFinite(cookie.expires)
40
+ ? cookie.expires
41
+ : typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate)
42
+ ? cookie.expirationDate
43
+ : Date.now() + 1000 * 60 * 60 * 24 * 365,
44
+ };
45
+ });
46
+ }
47
+
48
+ public async saveSession(path: string, session: SessionData): Promise<void> {
49
+ await this.sessionRepository.write(path, session);
50
+ }
51
+
52
+ public async loadSession(path: string): Promise<SessionData | null> {
53
+ return this.sessionRepository.read(path);
54
+ }
55
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * E2EEService - extension point for end-to-end encrypted media operations.
3
+ *
4
+ * The JS/TS side of this bridge keeps E2EE media upload/download separate
5
+ * from the non-E2EE Messenger gateway. fca-unofficial speaks to the
6
+ * standard Messenger LightSpeed API (non-E2EE) only.
7
+ *
8
+ * This service therefore acts as a typed facade that:
9
+ * 1. Documents the contract (what operations are expected, their inputs/outputs).
10
+ * 2. Provides an extension point where a future native-addon or WASM layer
11
+ * can be plugged in.
12
+ * 3. Exposes a guard so callers know E2EE is not yet connected.
13
+ *
14
+ * Concrete implementations can extend or replace this class.
15
+ */
16
+
17
+ import type {
18
+ E2EEDownloadOptions,
19
+ E2EEDownloadResult,
20
+ E2EESendAudioOptions,
21
+ E2EESendDocumentOptions,
22
+ E2EESendImageOptions,
23
+ E2EESendStickerOptions,
24
+ E2EESendVideoOptions,
25
+ E2EEUploadResult,
26
+ } from "../models/e2ee.ts";
27
+ import type { E2EEClient } from "../e2ee/application/e2ee-client.ts";
28
+ import type { MediaUploadConfig } from "../models/media.ts";
29
+ import { MediaType, type MediaTypeKey } from "../e2ee/media/media-crypto.ts";
30
+
31
+ export class E2EEService {
32
+ private _connected = false;
33
+ private e2eeClient?: E2EEClient;
34
+ private uploadConfig?: MediaUploadConfig;
35
+
36
+ public setProvider(client: E2EEClient, uploadConfig: MediaUploadConfig): void {
37
+ this.e2eeClient = client;
38
+ this.uploadConfig = uploadConfig;
39
+ this._connected = true;
40
+ }
41
+
42
+ public get isConnected(): boolean {
43
+ return this._connected;
44
+ }
45
+
46
+ /** Called by an external E2EE provider when it establishes a connection. */
47
+ public markConnected(): void {
48
+ this._connected = true;
49
+ }
50
+
51
+ /** Called when the E2EE connection drops. */
52
+ public markDisconnected(): void {
53
+ this._connected = false;
54
+ }
55
+
56
+ public ensureEnabled(): void {
57
+ if (!this._connected || !this.e2eeClient) {
58
+ throw new Error("E2EE provider not connected");
59
+ }
60
+ }
61
+
62
+ public getClient(): E2EEClient {
63
+ this.ensureEnabled();
64
+ return this.e2eeClient!;
65
+ }
66
+
67
+ // Typed implementations wrapping E2EEClient
68
+
69
+ /** Send an E2EE image. Requires a concrete provider implementation. */
70
+ public async sendImage(opts: E2EESendImageOptions): Promise<E2EEUploadResult> {
71
+ this.ensureEnabled();
72
+ await this.e2eeClient!.encryptAndUploadMedia(
73
+ this.uploadConfig!,
74
+ opts.data,
75
+ "image",
76
+ opts.mimeType || "image/jpeg"
77
+ );
78
+ // TODO: Send ConsumerApplication protobuf via Transport
79
+ return {
80
+ messageId: "mock-id",
81
+ timestampMs: Date.now()
82
+ };
83
+ }
84
+
85
+ /** Send an E2EE video. */
86
+ public async sendVideo(opts: E2EESendVideoOptions): Promise<E2EEUploadResult> {
87
+ this.ensureEnabled();
88
+ await this.e2eeClient!.encryptAndUploadMedia(
89
+ this.uploadConfig!,
90
+ opts.data,
91
+ "video",
92
+ opts.mimeType || "video/mp4"
93
+ );
94
+ return { messageId: "mock-id", timestampMs: Date.now() };
95
+ }
96
+
97
+ /** Send an E2EE audio/voice message. */
98
+ public async sendAudio(opts: E2EESendAudioOptions): Promise<E2EEUploadResult> {
99
+ this.ensureEnabled();
100
+ await this.e2eeClient!.encryptAndUploadMedia(
101
+ this.uploadConfig!,
102
+ opts.data,
103
+ "audio",
104
+ opts.mimeType || "audio/ogg; codecs=opus"
105
+ );
106
+ return { messageId: "mock-id", timestampMs: Date.now() };
107
+ }
108
+
109
+ /** Send an E2EE document/file. */
110
+ public async sendDocument(opts: E2EESendDocumentOptions): Promise<E2EEUploadResult> {
111
+ this.ensureEnabled();
112
+ await this.e2eeClient!.encryptAndUploadMedia(
113
+ this.uploadConfig!,
114
+ opts.data,
115
+ "document",
116
+ opts.mimeType || "application/octet-stream"
117
+ );
118
+ return { messageId: "mock-id", timestampMs: Date.now() };
119
+ }
120
+
121
+ /** Send an E2EE sticker. */
122
+ public async sendSticker(opts: E2EESendStickerOptions): Promise<E2EEUploadResult> {
123
+ this.ensureEnabled();
124
+ await this.e2eeClient!.encryptAndUploadMedia(
125
+ this.uploadConfig!,
126
+ opts.data,
127
+ "image",
128
+ opts.mimeType || "image/webp"
129
+ );
130
+ return { messageId: "mock-id", timestampMs: Date.now() };
131
+ }
132
+
133
+ /** Download E2EE media by decrypting it with the provided keys. */
134
+ public async downloadMedia(opts: E2EEDownloadOptions): Promise<E2EEDownloadResult> {
135
+ this.ensureEnabled();
136
+ // Fetch encrypted payload from CDN
137
+ const resp = await fetch(opts.directPath);
138
+ if (!resp.ok) {
139
+ throw new Error(`Failed to fetch media from CDN: ${resp.status}`);
140
+ }
141
+ const encryptedData = Buffer.from(await resp.arrayBuffer());
142
+
143
+ // Determine MediaTypeKey
144
+ let type: MediaTypeKey;
145
+ switch (opts.mediaType) {
146
+ case "image": type = "image"; break;
147
+ case "video": type = "video"; break;
148
+ case "audio": type = "audio"; break;
149
+ case "voice": type = "audio"; break;
150
+ case "document": type = "document"; break;
151
+ case "sticker": type = "image"; break; // stickers use IMAGE crypto
152
+ default: type = "document"; break;
153
+ }
154
+
155
+ // Decrypt
156
+ const mediaKey = Buffer.from(opts.mediaKey, "base64");
157
+ const fileSHA256 = Buffer.from(opts.mediaSha256, "base64");
158
+ const fileEncSHA256 = opts.mediaEncSha256 ? Buffer.from(opts.mediaEncSha256, "base64") : undefined;
159
+
160
+ const decrypted = this.e2eeClient!.decryptMedia({
161
+ data: encryptedData,
162
+ mediaKey,
163
+ type: type,
164
+ fileSHA256,
165
+ fileEncSHA256
166
+ });
167
+
168
+ return {
169
+ data: decrypted,
170
+ mimeType: opts.mimeType || "application/octet-stream",
171
+ fileSize: decrypted.length
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,245 @@
1
+ import { createRequire } from "node:module";
2
+ import { Readable } from "node:stream";
3
+ import { Buffer } from "node:buffer";
4
+
5
+ import type { FCAApi, LoginData } from "fca-unofficial";
6
+ import { createHmac, createHash } from "node:crypto";
7
+ import { logger } from "../utils/logger.ts";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const fcaLogin = require("fca-unofficial") as (
11
+ loginData: LoginData,
12
+ options: Record<string, unknown>,
13
+ callback: (err: unknown, api?: FCAApi) => void,
14
+ ) => void;
15
+
16
+ function normalizeError(error: unknown): Error {
17
+ if (error instanceof Error) {
18
+ return error;
19
+ }
20
+ if (typeof error === "string") {
21
+ return new Error(error);
22
+ }
23
+ return new Error("Unknown FCA error");
24
+ }
25
+
26
+ export class FacebookGatewayService {
27
+ public async login(appState: LoginData["appState"]): Promise<FCAApi> {
28
+ return new Promise<FCAApi>((resolve, reject) => {
29
+ fcaLogin({ appState }, {}, (err, api) => {
30
+ if (err) {
31
+ reject(normalizeError(err));
32
+ return;
33
+ }
34
+ if (!api) {
35
+ reject(new Error("Login succeeded without API instance"));
36
+ return;
37
+ }
38
+ resolve(api);
39
+ });
40
+ });
41
+ }
42
+
43
+ public configure(api: FCAApi): void {
44
+ api.setOptions?.({
45
+ selfListen: false,
46
+ listenEvents: true,
47
+ autoMarkRead: false,
48
+ autoMarkDelivery: false,
49
+ online: true,
50
+ });
51
+ }
52
+
53
+ public async startListening(
54
+ api: FCAApi,
55
+ onEvent: (event: Record<string, unknown>) => void,
56
+ onError: (error: Error) => void,
57
+ ): Promise<void> {
58
+ await Promise.resolve(
59
+ api.listenMqtt((err, event) => {
60
+ if (err) {
61
+ onError(normalizeError(err));
62
+ return;
63
+ }
64
+ if (event && typeof event === "object") {
65
+ onEvent(event);
66
+ }
67
+ }),
68
+ );
69
+ }
70
+
71
+ public async sendMessage(
72
+ api: FCAApi,
73
+ threadId: string,
74
+ text: string,
75
+ replyToMessageId?: string,
76
+ ): Promise<Record<string, unknown>> {
77
+ const response = await Promise.resolve(api.sendMessage(text, threadId, undefined, replyToMessageId));
78
+ return (response ?? {}) as Record<string, unknown>;
79
+ }
80
+
81
+ public async sendAttachmentMessage(
82
+ api: FCAApi,
83
+ input: {
84
+ threadId: string;
85
+ data: Buffer;
86
+ fileName: string;
87
+ caption?: string;
88
+ replyToMessageId?: string;
89
+ },
90
+ ): Promise<Record<string, unknown>> {
91
+ const stream = Readable.from(input.data);
92
+ Object.assign(stream, { path: input.fileName });
93
+
94
+ const payload = {
95
+ body: input.caption ?? "",
96
+ attachment: stream,
97
+ };
98
+
99
+ const response = await Promise.resolve(
100
+ api.sendMessage(payload, input.threadId, undefined, input.replyToMessageId),
101
+ );
102
+ return (response ?? {}) as Record<string, unknown>;
103
+ }
104
+
105
+ public async sendReaction(
106
+ api: FCAApi,
107
+ messageId: string,
108
+ reaction: string,
109
+ ): Promise<void> {
110
+ if (!api.setMessageReaction) {
111
+ throw new Error("setMessageReaction is not available in fca-unofficial");
112
+ }
113
+ await Promise.resolve(api.setMessageReaction(reaction, messageId, undefined, true));
114
+ }
115
+
116
+ public async unsendMessage(api: FCAApi, messageId: string): Promise<void> {
117
+ if (!api.unsendMessage) {
118
+ throw new Error("unsendMessage is not available in fca-unofficial");
119
+ }
120
+ await Promise.resolve(api.unsendMessage(messageId));
121
+ }
122
+
123
+ public async sendTyping(api: FCAApi, threadId: string, isTyping: boolean): Promise<void> {
124
+ if (!api.sendTypingIndicator) {
125
+ throw new Error("sendTypingIndicator is not available in fca-unofficial");
126
+ }
127
+ await Promise.resolve(api.sendTypingIndicator(isTyping, threadId));
128
+ }
129
+
130
+ public async markAsRead(api: FCAApi, threadId: string): Promise<void> {
131
+ if (!api.markAsRead) {
132
+ throw new Error("markAsRead is not available in fca-unofficial");
133
+ }
134
+ await Promise.resolve(api.markAsRead(threadId, true));
135
+ }
136
+
137
+ public async sendStickerMessage(
138
+ api: FCAApi,
139
+ input: {
140
+ threadId: string;
141
+ stickerId: number;
142
+ replyToMessageId?: string;
143
+ },
144
+ ): Promise<Record<string, unknown>> {
145
+ const payload = { sticker: input.stickerId };
146
+ const response = await Promise.resolve(
147
+ api.sendMessage(payload, input.threadId, undefined, input.replyToMessageId),
148
+ );
149
+ return (response ?? {}) as Record<string, unknown>;
150
+ }
151
+
152
+ public stop(api: FCAApi): void {
153
+ api.stopListenMqtt?.();
154
+ }
155
+
156
+ /**
157
+ * Fetch the Crypto Auth Token (CAT) required for E2EE connection.
158
+ * This uses the MAWCatQuery GraphQL document.
159
+ */
160
+ public async fetchCAT(api: FCAApi): Promise<string> {
161
+ const fb_dtsg = (api as any).fb_dtsg;
162
+ const userId = (api as any).getCurrentUserID();
163
+
164
+ logger.debug("FacebookGatewayService", "Fetching CAT via GraphQL...");
165
+ const resText = await (api as any).httpPost("https://www.facebook.com/api/graphql/", {
166
+ fb_dtsg,
167
+ variables: "{}",
168
+ doc_id: "23999698219677129",
169
+ __user: userId,
170
+ __a: "1",
171
+ __jssesw: "1",
172
+ server_timestamps: "true",
173
+ });
174
+
175
+ const cleanText = resText.replace("for (;;);", "").trim();
176
+ let data;
177
+ try {
178
+ data = JSON.parse(cleanText);
179
+ } catch (e) {
180
+ logger.error("FacebookGatewayService", "Failed to parse CAT response:", resText);
181
+ throw new Error("Failed to parse CAT response");
182
+ }
183
+
184
+ const cat = data?.data?.secure_message_over_wa_cat_query?.encrypted_serialized_cat;
185
+
186
+ if (!cat) {
187
+ logger.error("FacebookGatewayService", "CAT GraphQL response (no cat):", resText);
188
+ throw new Error("Failed to extract CAT token from GraphQL response");
189
+ }
190
+
191
+ logger.debug("FacebookGatewayService", `CAT fetched successfully. Length: ${cat.length}, Prefix: ${cat.slice(0, 20)}...`);
192
+ return cat;
193
+ }
194
+
195
+
196
+ /**
197
+ * Fetch ICDC metadata for the user.
198
+ */
199
+ public async fetchICDC(api: FCAApi, fbid: string, deviceId: string, fbCat: Buffer): Promise<any> {
200
+ if (typeof (api as any).httpPost !== "function") {
201
+ throw new Error("api.httpPost is required for ICDC fetch");
202
+ }
203
+
204
+ const resText = await (api as any).httpPost("https://reg-e2ee.facebook.com/v2/fb_icdc_fetch", {
205
+ fbid: fbid,
206
+ fb_cat: fbCat.toString("utf8"), // Assuming it's the base64 string
207
+ app_id: "256002347743983",
208
+ device_id: deviceId,
209
+ });
210
+
211
+ if (!resText) throw new Error("Empty response from icdc_fetch");
212
+ return JSON.parse(resText);
213
+ }
214
+
215
+ /**
216
+ * Register the device for ICDC.
217
+ */
218
+ public async registerICDC(api: FCAApi, fbid: string, deviceId: string, fbCat: Buffer, payload: any): Promise<any> {
219
+ if (typeof (api as any).httpPost !== "function") {
220
+ throw new Error("api.httpPost is required for ICDC register");
221
+ }
222
+
223
+ const fullPayload = {
224
+ fbid: fbid,
225
+ fb_cat: fbCat.toString("base64"),
226
+ app_id: "256002347743983",
227
+ device_id: deviceId,
228
+ ...payload,
229
+ };
230
+ const appState = (api as any).getAppState?.();
231
+ const cookies = (appState as any[] || []).map(c => `${c.key}=${c.value}`).join("; ");
232
+ const userAgent = "Facebook Messenger/441.1.0.32.115 (Android 13; 480dpi; 1080x2236; Xiaomi; 2210132G; cupid; qcom; en_US; 555627749)";
233
+
234
+ const params = new URLSearchParams();
235
+ for (const [key, value] of Object.entries(fullPayload)) {
236
+ params.append(key, String(value));
237
+ }
238
+
239
+ logger.debug("FacebookGatewayService", "Sending ICDC registration via api.httpPost...");
240
+ const resText = await (api as any).httpPost("https://reg-e2ee.facebook.com/v2/fb_register_v2", fullPayload);
241
+ logger.debug("FacebookGatewayService", "Raw Register Response:", resText);
242
+ if (!resText) throw new Error("Empty response from icdc_register");
243
+ return JSON.parse(resText);
244
+ }
245
+ }