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.
- package/DOCS.md +310 -0
- package/LICENSE +660 -0
- package/README.md +153 -0
- package/bun.lock +1014 -0
- package/examples/basic-usage.ts +52 -0
- package/jest.config.ts +18 -0
- package/package.json +56 -0
- package/src/config/env.ts +15 -0
- package/src/controllers/client.controller.ts +805 -0
- package/src/controllers/dgw-handler.ts +171 -0
- package/src/controllers/e2ee-handler.ts +832 -0
- package/src/controllers/event-mapper.ts +327 -0
- package/src/core/client.ts +151 -0
- package/src/e2ee/application/e2ee-client.ts +376 -0
- package/src/e2ee/application/fanout-planner.ts +79 -0
- package/src/e2ee/application/outbound-message-cache.ts +58 -0
- package/src/e2ee/application/prekey-maintenance.ts +55 -0
- package/src/e2ee/application/retry-manager.ts +156 -0
- package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
- package/src/e2ee/facebook/icdc-payload.ts +31 -0
- package/src/e2ee/index.ts +76 -0
- package/src/e2ee/internal.ts +27 -0
- package/src/e2ee/media/media-crypto.ts +147 -0
- package/src/e2ee/media/media-upload.ts +90 -0
- package/src/e2ee/message/builders/client-payload.ts +54 -0
- package/src/e2ee/message/builders/consumer-application.ts +362 -0
- package/src/e2ee/message/builders/message-application.ts +64 -0
- package/src/e2ee/message/builders/message-transport.ts +84 -0
- package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
- package/src/e2ee/message/constants.ts +4 -0
- package/src/e2ee/message/message-builder.ts +15 -0
- package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
- package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
- package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
- package/src/e2ee/message/proto/MessageApplication.proto +82 -0
- package/src/e2ee/message/proto/MessageTransport.proto +77 -0
- package/src/e2ee/message/proto/WACommon.proto +66 -0
- package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
- package/src/e2ee/message/proto/proto-writer.ts +76 -0
- package/src/e2ee/signal/prekey-manager.ts +140 -0
- package/src/e2ee/signal/signal-manager.ts +365 -0
- package/src/e2ee/store/device-json.ts +47 -0
- package/src/e2ee/store/device-repository.ts +12 -0
- package/src/e2ee/store/device-store.ts +538 -0
- package/src/e2ee/transport/binary/decoder.ts +180 -0
- package/src/e2ee/transport/binary/encoder.ts +143 -0
- package/src/e2ee/transport/binary/stanzas.ts +97 -0
- package/src/e2ee/transport/binary/tokens.ts +64 -0
- package/src/e2ee/transport/binary/wa-binary.ts +8 -0
- package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
- package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
- package/src/e2ee/transport/noise/noise-socket.ts +230 -0
- package/src/index.ts +34 -0
- package/src/models/client.ts +26 -0
- package/src/models/config.ts +14 -0
- package/src/models/domain.ts +299 -0
- package/src/models/e2ee.ts +295 -0
- package/src/models/media.ts +41 -0
- package/src/models/messaging.ts +88 -0
- package/src/models/thread.ts +69 -0
- package/src/repositories/session.repository.ts +20 -0
- package/src/services/auth.service.ts +55 -0
- package/src/services/e2ee.service.ts +174 -0
- package/src/services/facebook-gateway.service.ts +245 -0
- package/src/services/icdc.service.ts +177 -0
- package/src/services/media.service.ts +304 -0
- package/src/services/messaging.service.ts +28 -0
- package/src/services/thread.service.ts +199 -0
- package/src/types/advanced-types.ts +61 -0
- package/src/types/fca-unofficial.d.ts +212 -0
- package/src/types/protocol-types.ts +43 -0
- package/src/utils/fca-utils.ts +30 -0
- package/src/utils/logger.ts +24 -0
- package/src/utils/mime.ts +51 -0
- package/tests/.env.example +87 -0
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +1 -0
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +498 -0
- package/tests/script/echo-e2ee.ts +174 -0
- package/tests/script/send-e2ee-media.ts +227 -0
- package/tests/script/send-e2ee-reaction.ts +108 -0
- package/tests/script/send-e2ee-unsend.ts +115 -0
- package/tests/script/send-typing.ts +107 -0
- package/tests/script/test-group-send.ts +105 -0
- package/tests/setup.ts +3 -0
- package/tests/types.test.ts +57 -0
- package/tests/unit/controllers/dgw-handler.test.ts +60 -0
- package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
- package/tests/unit/controllers/event-mapper.test.ts +252 -0
- package/tests/unit/e2ee/application-helpers.test.ts +418 -0
- package/tests/unit/e2ee/device-store.test.ts +126 -0
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
- package/tests/unit/e2ee/media-crypto.test.ts +55 -0
- package/tests/unit/e2ee/media-upload.test.ts +60 -0
- package/tests/unit/e2ee/message-builder.test.ts +42 -0
- package/tests/unit/e2ee/message-builders.test.ts +230 -0
- package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
- package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
- package/tests/unit/e2ee/wa-binary.test.ts +127 -0
- package/tests/unit/services/e2ee.service.test.ts +101 -0
- package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
- package/tests/unit/services/media.service.test.ts +169 -0
- package/tests/unit/utils/fca-utils.test.ts +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { FCAApi } from "fca-unofficial";
|
|
4
|
+
|
|
5
|
+
import type { MessengerEvent } from "../models/domain.ts";
|
|
6
|
+
import {
|
|
7
|
+
unmarshal,
|
|
8
|
+
encodeNode,
|
|
9
|
+
marshal as marshalBinary,
|
|
10
|
+
buildUnifiedSessionId,
|
|
11
|
+
encodeKeepAlive,
|
|
12
|
+
encodePresenceAvailable,
|
|
13
|
+
encodePrimingNode,
|
|
14
|
+
encodeSetPassive,
|
|
15
|
+
} from "../e2ee/transport/binary/wa-binary.ts";
|
|
16
|
+
import type { DGWEndpointKind } from "../e2ee/transport/dgw/dgw-socket.ts";
|
|
17
|
+
import type { Node } from "../e2ee/transport/binary/wa-binary.ts";
|
|
18
|
+
import type { SessionData } from "../models/client.ts";
|
|
19
|
+
import type { MediaUploadConfig } from "../models/media.ts";
|
|
20
|
+
import type { MediaFields } from "../models/e2ee.ts";
|
|
21
|
+
import type {
|
|
22
|
+
CreateThreadInput,
|
|
23
|
+
DeleteThreadInput,
|
|
24
|
+
DownloadMediaInput,
|
|
25
|
+
GetUserInfoInput,
|
|
26
|
+
MarkReadInput,
|
|
27
|
+
MuteThreadInput,
|
|
28
|
+
RenameThreadInput,
|
|
29
|
+
SearchUsersInput,
|
|
30
|
+
SendMediaInput,
|
|
31
|
+
SendMessageInput,
|
|
32
|
+
SendReactionInput,
|
|
33
|
+
SendStickerInput,
|
|
34
|
+
SetGroupPhotoInput,
|
|
35
|
+
TypingInput,
|
|
36
|
+
} from "../models/messaging.ts";
|
|
37
|
+
import type { AuthConfig } from "../models/config.ts";
|
|
38
|
+
import { AuthService } from "../services/auth.service.ts";
|
|
39
|
+
import type { E2EEService } from "../services/e2ee.service.ts";
|
|
40
|
+
import { FacebookGatewayService } from "../services/facebook-gateway.service.ts";
|
|
41
|
+
import { MediaService } from "../services/media.service.ts";
|
|
42
|
+
import { MessagingService } from "../services/messaging.service.ts";
|
|
43
|
+
import { ICDCService } from "../services/icdc.service.ts";
|
|
44
|
+
import type {
|
|
45
|
+
AddGroupMemberInput,
|
|
46
|
+
ChangeAdminStatusInput,
|
|
47
|
+
CreatePollInput,
|
|
48
|
+
EditMessageInput,
|
|
49
|
+
ForwardAttachmentInput,
|
|
50
|
+
GetThreadHistoryInput,
|
|
51
|
+
GetThreadListInput,
|
|
52
|
+
RemoveGroupMemberInput,
|
|
53
|
+
} from "../models/thread.ts";
|
|
54
|
+
import { ThreadService } from "../services/thread.service.ts";
|
|
55
|
+
|
|
56
|
+
import { DeviceStore } from "../e2ee/store/device-store.ts";
|
|
57
|
+
import { E2EEClient } from "../e2ee/application/e2ee-client.ts";
|
|
58
|
+
import type { MediaTypeKey } from "../e2ee/media/media-crypto.ts";
|
|
59
|
+
import { FacebookE2EESocket } from "../e2ee/transport/noise/noise-socket.ts";
|
|
60
|
+
import { FacebookDGWSocket } from "../e2ee/transport/dgw/dgw-socket.ts";
|
|
61
|
+
import { encodeClientPayload } from "../e2ee/message/message-builder.ts";
|
|
62
|
+
import { str, now } from "../utils/fca-utils.ts";
|
|
63
|
+
import { logger } from "../utils/logger.ts";
|
|
64
|
+
import { EventMapper } from "./event-mapper.ts";
|
|
65
|
+
import { DGWHandler } from "./dgw-handler.ts";
|
|
66
|
+
import { E2EEHandler } from "./e2ee-handler.ts";
|
|
67
|
+
import { OutboundMessageCache } from "../e2ee/application/outbound-message-cache.ts";
|
|
68
|
+
import { E2EERetryManager } from "../e2ee/application/retry-manager.ts";
|
|
69
|
+
import { PreKeyMaintenance } from "../e2ee/application/prekey-maintenance.ts";
|
|
70
|
+
import {
|
|
71
|
+
buildParticipantListHash,
|
|
72
|
+
normalizeDMThreadToJid,
|
|
73
|
+
sameMessengerDevice,
|
|
74
|
+
sameMessengerUser,
|
|
75
|
+
toBareMessengerJid,
|
|
76
|
+
uniqueJids,
|
|
77
|
+
} from "../e2ee/application/fanout-planner.ts";
|
|
78
|
+
|
|
79
|
+
export class ClientController {
|
|
80
|
+
private api: FCAApi | null = null;
|
|
81
|
+
private dgwSocket: FacebookDGWSocket | null = null;
|
|
82
|
+
private e2eeSocket: FacebookE2EESocket | null = null;
|
|
83
|
+
private activeDeviceStore: DeviceStore | null = null;
|
|
84
|
+
private e2eeConnected: boolean = false;
|
|
85
|
+
private heartbeatInterval?: NodeJS.Timeout;
|
|
86
|
+
private userId: string = "";
|
|
87
|
+
private readonly outgoingE2EECache = new OutboundMessageCache();
|
|
88
|
+
private e2eeUploadConfig: MediaUploadConfig | null = null;
|
|
89
|
+
|
|
90
|
+
private readonly eventMapper: EventMapper;
|
|
91
|
+
private readonly dgwHandler: DGWHandler;
|
|
92
|
+
private readonly e2eeHandler: E2EEHandler;
|
|
93
|
+
private readonly retryManager: E2EERetryManager;
|
|
94
|
+
private readonly preKeyMaintenance: PreKeyMaintenance;
|
|
95
|
+
|
|
96
|
+
public constructor(
|
|
97
|
+
private readonly authService: AuthService,
|
|
98
|
+
private readonly gateway: FacebookGatewayService,
|
|
99
|
+
private readonly messagingService: MessagingService,
|
|
100
|
+
private readonly mediaService: MediaService,
|
|
101
|
+
private readonly threadService: ThreadService,
|
|
102
|
+
private readonly e2eeService: E2EEService,
|
|
103
|
+
private readonly icdcService: ICDCService,
|
|
104
|
+
private readonly eventBus: EventEmitter,
|
|
105
|
+
) {
|
|
106
|
+
this.eventMapper = new EventMapper(this.eventBus, this.mediaService, this.e2eeService);
|
|
107
|
+
this.dgwHandler = new DGWHandler(this.eventMapper);
|
|
108
|
+
this.e2eeHandler = new E2EEHandler(
|
|
109
|
+
this.eventMapper,
|
|
110
|
+
() => this.e2eeSocket,
|
|
111
|
+
() => this.activeDeviceStore,
|
|
112
|
+
node => this.retryManager.handleReceipt(node),
|
|
113
|
+
);
|
|
114
|
+
this.retryManager = new E2EERetryManager({
|
|
115
|
+
cache: this.outgoingE2EECache,
|
|
116
|
+
getClient: () => this.e2eeService.getClient(),
|
|
117
|
+
getSocket: () => this.e2eeSocket,
|
|
118
|
+
getSelfJid: () => this.getSelfE2EEJid(),
|
|
119
|
+
getPreKeyBundle: (jid) => this.e2eeHandler.getPreKeyBundle(jid),
|
|
120
|
+
});
|
|
121
|
+
this.preKeyMaintenance = new PreKeyMaintenance({
|
|
122
|
+
getSocket: () => this.e2eeSocket,
|
|
123
|
+
getStore: () => this.activeDeviceStore,
|
|
124
|
+
getServerPreKeyCount: () => this.e2eeHandler.getServerPreKeyCount(),
|
|
125
|
+
uploadPreKeys: (count) => this.e2eeHandler.uploadPreKeys(count),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Lifecycle
|
|
130
|
+
|
|
131
|
+
public async connect(authConfig: AuthConfig, sessionStorePath?: string): Promise<{ userId: string }> {
|
|
132
|
+
const appState = await this.authService.readAppState(authConfig);
|
|
133
|
+
const api = await this.gateway.login(appState);
|
|
134
|
+
this.gateway.configure(api);
|
|
135
|
+
|
|
136
|
+
const userId = str(api.getCurrentUserID?.());
|
|
137
|
+
|
|
138
|
+
const session: SessionData = {
|
|
139
|
+
userId,
|
|
140
|
+
appState: appState.map(cookie => ({ key: cookie.key, value: cookie.value })),
|
|
141
|
+
platform: authConfig.platform,
|
|
142
|
+
updatedAt: now(),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (sessionStorePath) {
|
|
146
|
+
await this.authService.saveSession(sessionStorePath, session);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.api = api;
|
|
150
|
+
|
|
151
|
+
void this.gateway.startListening(
|
|
152
|
+
api,
|
|
153
|
+
event => this.eventMapper.emitMappedEvent(event),
|
|
154
|
+
error =>
|
|
155
|
+
this.eventBus.emit("event", {
|
|
156
|
+
type: "error",
|
|
157
|
+
data: { message: error.message },
|
|
158
|
+
} satisfies MessengerEvent),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
this.userId = userId;
|
|
162
|
+
return { userId };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public async disconnect(): Promise<void> {
|
|
166
|
+
this.cleanup();
|
|
167
|
+
this.dgwSocket?.close();
|
|
168
|
+
this.dgwSocket = null;
|
|
169
|
+
|
|
170
|
+
this.e2eeSocket?.close();
|
|
171
|
+
this.e2eeSocket = null;
|
|
172
|
+
|
|
173
|
+
if (!this.api) return;
|
|
174
|
+
this.gateway.stop(this.api);
|
|
175
|
+
this.api = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// E2EE
|
|
179
|
+
|
|
180
|
+
public async sendNoiseKeepAlive(): Promise<void> {
|
|
181
|
+
if (!this.e2eeSocket) throw new Error("E2EE not connected");
|
|
182
|
+
const id = (now() % 1000).toString();
|
|
183
|
+
await this.e2eeSocket.sendFrame(encodeKeepAlive(id));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public async connectE2EE(deviceStorePath: string, userId: string): Promise<void> {
|
|
187
|
+
this.userId = userId;
|
|
188
|
+
const ds = await DeviceStore.fromFile(deviceStorePath);
|
|
189
|
+
this.activeDeviceStore = ds;
|
|
190
|
+
|
|
191
|
+
const client = new E2EEClient(ds);
|
|
192
|
+
this.e2eeUploadConfig = process.env.FB_E2EE_MEDIA_UPLOAD_AUTH
|
|
193
|
+
? {
|
|
194
|
+
host: process.env.FB_E2EE_MEDIA_UPLOAD_HOST ?? "rupload.facebook.com",
|
|
195
|
+
auth: process.env.FB_E2EE_MEDIA_UPLOAD_AUTH,
|
|
196
|
+
fetchedAtMs: now(),
|
|
197
|
+
}
|
|
198
|
+
: null;
|
|
199
|
+
this.e2eeService.setProvider(client, this.e2eeUploadConfig ?? {
|
|
200
|
+
host: process.env.FB_E2EE_MEDIA_UPLOAD_HOST ?? "rupload.facebook.com",
|
|
201
|
+
auth: "",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const endpoint = "wss://web-chat-e2ee.facebook.com/ws/chat?cid=client-" + now();
|
|
205
|
+
const noiseSocket = new FacebookE2EESocket(endpoint);
|
|
206
|
+
|
|
207
|
+
noiseSocket.on("connected", () => {
|
|
208
|
+
this.eventMapper.emit({ type: "e2ee_connected", data: {} });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
noiseSocket.on("disconnected", () => {
|
|
212
|
+
this.cleanup();
|
|
213
|
+
this.eventMapper.emit({ type: "disconnected", data: { isE2EE: true } });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
noiseSocket.on("error", (err) => {
|
|
217
|
+
this.eventMapper.emit({ type: "error", data: { message: err.message } });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
logger.debug("ClientController", "Fetching CAT...");
|
|
221
|
+
const fbCat = await this.gateway.fetchCAT(this.requireApi());
|
|
222
|
+
|
|
223
|
+
if (!ds.jidDevice) {
|
|
224
|
+
const api = this.requireApi();
|
|
225
|
+
const appState = (api as any).getAppState?.() || [];
|
|
226
|
+
const cookieStr = appState.map((c: any) => `${c.key}=${c.value}`).join("; ");
|
|
227
|
+
this.icdcService.setCookies(cookieStr);
|
|
228
|
+
|
|
229
|
+
logger.info("ClientController", "Registering new device via ICDC...");
|
|
230
|
+
const waDeviceId = await this.icdcService.register(userId, fbCat, "2220391788200892", ds);
|
|
231
|
+
ds.jidDevice = waDeviceId;
|
|
232
|
+
ds.jidUser = userId;
|
|
233
|
+
ds.saveToFile();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const clientPayload = encodeClientPayload({
|
|
237
|
+
username: BigInt(userId),
|
|
238
|
+
deviceId: ds.jidDevice ?? 0,
|
|
239
|
+
fbCatBase64: fbCat,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
noiseSocket.on("frame", async (rawFrame: Buffer) => {
|
|
243
|
+
if (rawFrame.length === 0) return;
|
|
244
|
+
try {
|
|
245
|
+
const node = unmarshal(rawFrame);
|
|
246
|
+
if (["receipt", "notification", "iq", "presence", "call", "chatstate"].includes(node.tag) && node.attrs.id) {
|
|
247
|
+
this.e2eeHandler.sendAck(node);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
switch (node.tag) {
|
|
251
|
+
case "success":
|
|
252
|
+
this.e2eeConnected = true;
|
|
253
|
+
if (node.attrs.jid) this.activeDeviceStore?.setJIDs(node.attrs.jid, node.attrs.jid);
|
|
254
|
+
// Send presence to start stream
|
|
255
|
+
await noiseSocket.sendFrame(encodePresenceAvailable("false"));
|
|
256
|
+
break;
|
|
257
|
+
case "iq":
|
|
258
|
+
this.e2eeHandler.handleIQ(node);
|
|
259
|
+
break;
|
|
260
|
+
case "presence":
|
|
261
|
+
this.dispatchPresence(node);
|
|
262
|
+
break;
|
|
263
|
+
case "receipt":
|
|
264
|
+
await this.e2eeHandler.handleReceipt(node);
|
|
265
|
+
break;
|
|
266
|
+
case "notification":
|
|
267
|
+
await this.e2eeHandler.handleNotification(node);
|
|
268
|
+
break;
|
|
269
|
+
case "message":
|
|
270
|
+
case "appdata":
|
|
271
|
+
await this.e2eeHandler.handleEncryptedMessage(node, userId, client);
|
|
272
|
+
break;
|
|
273
|
+
case "ib":
|
|
274
|
+
this.e2eeHandler.handleIB(node);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
logger.error("E2EE", "Frame error:", err);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await noiseSocket.connect(ds.noiseKeyPriv, clientPayload);
|
|
283
|
+
this.e2eeSocket = noiseSocket;
|
|
284
|
+
|
|
285
|
+
// Wait for success
|
|
286
|
+
await new Promise<void>((resolve, reject) => {
|
|
287
|
+
const timeout = setTimeout(() => reject(new Error("Handshake timeout")), 10000);
|
|
288
|
+
const onFrame = (frame: Buffer) => {
|
|
289
|
+
const node = unmarshal(frame);
|
|
290
|
+
if (node.tag === "success") {
|
|
291
|
+
noiseSocket.off("frame", onFrame);
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
resolve();
|
|
294
|
+
} else if (node.tag === "failure") {
|
|
295
|
+
noiseSocket.off("frame", onFrame);
|
|
296
|
+
clearTimeout(timeout);
|
|
297
|
+
reject(new Error(`Login failure: ${node.attrs.reason}`));
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
noiseSocket.on("frame", onFrame);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
this.eventBus.emit("event", { type: "e2ee_connected", data: {} } as any);
|
|
304
|
+
|
|
305
|
+
// Initial sync nodes
|
|
306
|
+
await noiseSocket.sendFrame(encodePrimingNode(buildUnifiedSessionId()));
|
|
307
|
+
await noiseSocket.sendFrame(encodeSetPassive("active-stream", false));
|
|
308
|
+
|
|
309
|
+
await this.preKeyMaintenance.sync("startup");
|
|
310
|
+
this.preKeyMaintenance.start();
|
|
311
|
+
|
|
312
|
+
// Proactively fetch media_conn config for media uploads
|
|
313
|
+
this.fetchMediaUploadConfigProactively().catch((err) => {
|
|
314
|
+
logger.warn("ClientController", "Proactive media_conn fetch failed (will retry on first media send):", err);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
this.startHeartbeat();
|
|
318
|
+
await this.connectDGWIfEnabled(userId);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private dispatchPresence(node: Node) {
|
|
322
|
+
const userId = node.attrs.from?.split("@")[0];
|
|
323
|
+
const type = node.attrs.type;
|
|
324
|
+
this.eventMapper.emit({
|
|
325
|
+
type: "presence",
|
|
326
|
+
data: {
|
|
327
|
+
userId,
|
|
328
|
+
isOnline: type === "available",
|
|
329
|
+
lastActiveTimestampMs: now(),
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private startHeartbeat() {
|
|
335
|
+
this.stopHeartbeat();
|
|
336
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
337
|
+
try {
|
|
338
|
+
if (!this.e2eeSocket) return;
|
|
339
|
+
await this.sendNoiseKeepAlive();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
logger.error("ClientController", "E2EE heartbeat failed:", err);
|
|
342
|
+
this.eventMapper.emit({
|
|
343
|
+
type: "error",
|
|
344
|
+
data: { message: `E2EE heartbeat failed: ${(err as Error).message}` },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}, 30000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private stopHeartbeat() {
|
|
351
|
+
if (this.heartbeatInterval) {
|
|
352
|
+
clearInterval(this.heartbeatInterval);
|
|
353
|
+
this.heartbeatInterval = undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private cleanup() {
|
|
358
|
+
this.stopHeartbeat();
|
|
359
|
+
this.preKeyMaintenance.stop();
|
|
360
|
+
this.e2eeConnected = false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async connectDGWIfEnabled(userId: string): Promise<void> {
|
|
364
|
+
if (process.env.FB_DGW_ENABLE !== "1") return;
|
|
365
|
+
|
|
366
|
+
const endpoints: Record<DGWEndpointKind, string | undefined> = {
|
|
367
|
+
lightspeed: process.env.FB_DGW_URL_LIGHTSPEED,
|
|
368
|
+
streamcontroller: process.env.FB_DGW_URL_STREAMCONTROLLER,
|
|
369
|
+
realtime: process.env.FB_DGW_URL_REALTIME,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (!Object.values(endpoints).some(Boolean)) return;
|
|
373
|
+
|
|
374
|
+
const api = this.requireApi();
|
|
375
|
+
const appState = (api as any).getAppState?.() || [];
|
|
376
|
+
const cookieHeader = appState.map((c: any) => `${c.key}=${c.value}`).join("; ");
|
|
377
|
+
|
|
378
|
+
const dgw = new FacebookDGWSocket();
|
|
379
|
+
dgw.on("connected", () => this.eventMapper.emit({ type: "raw", data: { source: "dgw", type: "connected" } }));
|
|
380
|
+
dgw.on("frame", (ev: any) => {
|
|
381
|
+
this.eventMapper.emit({ type: "raw", data: { source: "dgw", userId, ...ev } });
|
|
382
|
+
this.dgwHandler.handleDGWFrame({ ...ev, kind: ev.target });
|
|
383
|
+
});
|
|
384
|
+
dgw.on("error", (err) => this.eventMapper.emit({ type: "error", data: { message: err.message } }));
|
|
385
|
+
|
|
386
|
+
const bootstrapTargets = this.resolveDGWTargets(process.env.FB_DGW_BOOTSTRAP_TARGETS, ["lightspeed" as DGWEndpointKind], endpoints);
|
|
387
|
+
const dataTargets = this.resolveDGWTargets(process.env.FB_DGW_BOOTSTRAP_DATA_TARGETS, bootstrapTargets, endpoints);
|
|
388
|
+
|
|
389
|
+
await dgw.connect({
|
|
390
|
+
endpoints,
|
|
391
|
+
cookieHeader,
|
|
392
|
+
userAgent: process.env.FB_DGW_UA || "Mozilla/5.0",
|
|
393
|
+
origin: process.env.FB_DGW_ORIGIN || "https://www.facebook.com",
|
|
394
|
+
referer: process.env.FB_DGW_REFERER || "https://www.facebook.com/",
|
|
395
|
+
acceptLanguage: process.env.FB_DGW_ACCEPT_LANGUAGE || "en-US,en;q=0.9",
|
|
396
|
+
pingIntervalMs: Number(process.env.FB_DGW_PING_INTERVAL_MS ?? "15000"),
|
|
397
|
+
bootstrap: {
|
|
398
|
+
targets: bootstrapTargets,
|
|
399
|
+
streamId: Number(process.env.FB_DGW_STREAM_ID ?? "1"),
|
|
400
|
+
dataTargets,
|
|
401
|
+
dataPayload: undefined,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
for (const target of dataTargets) {
|
|
406
|
+
const url = endpoints[target];
|
|
407
|
+
if (!url) continue;
|
|
408
|
+
const deviceId = new URL(url).searchParams.get("x-dgw-deviceid") || "";
|
|
409
|
+
const payload = this.dgwHandler.buildDGWBootstrapDataPayload(userId, deviceId);
|
|
410
|
+
if (payload) dgw.sendDataFrame(target, Number(process.env.FB_DGW_STREAM_ID ?? "1"), payload, true, 0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.dgwSocket = dgw;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private resolveDGWTargets(raw: string | undefined, fallback: DGWEndpointKind[], endpoints: Record<DGWEndpointKind, any>): DGWEndpointKind[] {
|
|
417
|
+
const allowed: DGWEndpointKind[] = ["lightspeed", "streamcontroller", "realtime"];
|
|
418
|
+
const base = (raw ?? "").split(",").map(s => s.trim()).filter((s): s is DGWEndpointKind => allowed.includes(s as DGWEndpointKind));
|
|
419
|
+
return (base.length > 0 ? base : fallback).filter(t => !!endpoints[t]);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Messaging delegate methods
|
|
423
|
+
|
|
424
|
+
public async sendMessage(input: SendMessageInput): Promise<Record<string, unknown>> {
|
|
425
|
+
const isE2EE = this.isE2EEThreadId(input.threadId);
|
|
426
|
+
const isGroup = input.threadId.includes("@g.us") || input.threadId.includes(".g.");
|
|
427
|
+
|
|
428
|
+
if (this.e2eeConnected && isE2EE) {
|
|
429
|
+
if (isGroup) {
|
|
430
|
+
await this.sendE2EEGroupText(input.threadId, input.text, input.replyToMessageId);
|
|
431
|
+
} else {
|
|
432
|
+
await this.sendE2EEText(input.threadId, input.text, input.replyToMessageId);
|
|
433
|
+
}
|
|
434
|
+
return { messageId: `e2ee-${now()}`, timestampMs: now() };
|
|
435
|
+
}
|
|
436
|
+
return this.messagingService.sendText(this.requireApi(), input);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
public async sendE2EEText(threadId: string, text: string, replyToMessageId?: string): Promise<void> {
|
|
440
|
+
if (!this.e2eeSocket) throw new Error("E2EE not connected");
|
|
441
|
+
const e2eeClient = this.e2eeService.getClient();
|
|
442
|
+
const selfJid = this.getSelfE2EEJid();
|
|
443
|
+
const toJid = normalizeDMThreadToJid(threadId);
|
|
444
|
+
const messageId = String(BigInt(Math.floor(Math.random() * 1e15)));
|
|
445
|
+
|
|
446
|
+
const result = await e2eeClient.buildDMTextFanoutPayloads({
|
|
447
|
+
toJid,
|
|
448
|
+
selfJid,
|
|
449
|
+
text,
|
|
450
|
+
isGroup: false,
|
|
451
|
+
replyToId: replyToMessageId,
|
|
452
|
+
replyToSenderJid: replyToMessageId ? toJid : undefined,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const participantNodes: Buffer[] = [];
|
|
456
|
+
const deviceJids = uniqueJids(await this.e2eeHandler.getDeviceList([toJid, toBareMessengerJid(selfJid)]));
|
|
457
|
+
if (deviceJids.length === 0) {
|
|
458
|
+
logger.warn("ClientController", `No E2EE devices discovered for ${toJid}; sending empty participant list`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const deviceJid of deviceJids) {
|
|
462
|
+
if (sameMessengerDevice(deviceJid, selfJid)) continue;
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
if (!(await e2eeClient.hasSession(deviceJid))) {
|
|
466
|
+
logger.info("ClientController", `Establishing new session with ${deviceJid}`);
|
|
467
|
+
const bundle = await this.e2eeHandler.getPreKeyBundle(deviceJid);
|
|
468
|
+
await e2eeClient.establishSession(deviceJid, bundle);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const payload = sameMessengerUser(deviceJid, selfJid)
|
|
472
|
+
? result.selfDevicePayload
|
|
473
|
+
: result.devicePayload;
|
|
474
|
+
const encrypted = await e2eeClient.encryptDevicePayload(deviceJid, selfJid, payload);
|
|
475
|
+
|
|
476
|
+
participantNodes.push(encodeNode("to", { jid: deviceJid }, [
|
|
477
|
+
encodeNode("enc", { v: "3", type: encrypted.type }, encrypted.ciphertext),
|
|
478
|
+
]));
|
|
479
|
+
} catch (err) {
|
|
480
|
+
logger.error("ClientController", `Failed to encrypt DM fanout to ${deviceJid}:`, err);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const msgNode = encodeNode("message", { to: toJid, type: "text", id: messageId }, [
|
|
485
|
+
encodeNode("participants", {}, participantNodes),
|
|
486
|
+
encodeNode("franking", {}, [
|
|
487
|
+
encodeNode("franking_tag", {}, result.frankingTag),
|
|
488
|
+
]),
|
|
489
|
+
encodeNode("trace", {}, [
|
|
490
|
+
encodeNode("request_id", {}, Buffer.from(randomUUID().replace(/-/g, ""), "hex")),
|
|
491
|
+
]),
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
await this.e2eeSocket.sendFrame(marshalBinary(msgNode));
|
|
495
|
+
this.outgoingE2EECache.remember({
|
|
496
|
+
kind: "dm",
|
|
497
|
+
chatJid: toJid,
|
|
498
|
+
messageId,
|
|
499
|
+
messageType: "text",
|
|
500
|
+
messageApp: result.messageApp,
|
|
501
|
+
frankingTag: result.frankingTag,
|
|
502
|
+
createdAtMs: now(),
|
|
503
|
+
});
|
|
504
|
+
logger.info("ClientController", `E2EE DM message sent to ${toJid} with ${participantNodes.length} devices`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
public async sendE2EEGroupText(groupJid: string, text: string, replyToMessageId?: string): Promise<void> {
|
|
508
|
+
if (!this.e2eeSocket) throw new Error("E2EE not connected");
|
|
509
|
+
const e2eeClient = this.e2eeService.getClient();
|
|
510
|
+
const selfJid = this.getSelfE2EEJid();
|
|
511
|
+
|
|
512
|
+
// Fetch group participants
|
|
513
|
+
logger.debug("ClientController", `Fetching participants for group: ${groupJid}`);
|
|
514
|
+
const memberJids = await this.e2eeHandler.getGroupParticipants(groupJid);
|
|
515
|
+
|
|
516
|
+
// Fetch device list for all members
|
|
517
|
+
const deviceUsers = uniqueJids([...memberJids, toBareMessengerJid(selfJid)]);
|
|
518
|
+
logger.debug("ClientController", `Fetching devices for ${deviceUsers.length} members`);
|
|
519
|
+
const deviceJids = uniqueJids(await this.e2eeHandler.getDeviceList(deviceUsers))
|
|
520
|
+
.filter((jid) => !sameMessengerDevice(jid, selfJid));
|
|
521
|
+
const messageId = String(BigInt(Math.floor(Math.random() * 1e15)));
|
|
522
|
+
|
|
523
|
+
// Encrypt the main group payload
|
|
524
|
+
const result = await e2eeClient.encryptGroupText(
|
|
525
|
+
groupJid,
|
|
526
|
+
selfJid,
|
|
527
|
+
text,
|
|
528
|
+
messageId,
|
|
529
|
+
replyToMessageId,
|
|
530
|
+
undefined
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// Distribute SKDM to all devices
|
|
534
|
+
const participantNodes: Buffer[] = [];
|
|
535
|
+
for (const deviceJid of deviceJids) {
|
|
536
|
+
try {
|
|
537
|
+
// Establish session if missing
|
|
538
|
+
if (!(await e2eeClient.hasSession(deviceJid))) {
|
|
539
|
+
logger.info("ClientController", `Establishing new session with ${deviceJid}`);
|
|
540
|
+
const bundle = await this.e2eeHandler.getPreKeyBundle(deviceJid);
|
|
541
|
+
await e2eeClient.establishSession(deviceJid, bundle);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const payload = sameMessengerUser(deviceJid, selfJid)
|
|
545
|
+
? result.selfDevicePayload
|
|
546
|
+
: result.devicePayload;
|
|
547
|
+
const skdmEnc = await e2eeClient.encryptDevicePayload(deviceJid, selfJid, payload);
|
|
548
|
+
|
|
549
|
+
participantNodes.push(encodeNode("to", { jid: deviceJid }, [
|
|
550
|
+
encodeNode("enc", { v: "3", type: skdmEnc.type }, skdmEnc.ciphertext)
|
|
551
|
+
]));
|
|
552
|
+
} catch (err) {
|
|
553
|
+
logger.error("ClientController", `Failed to distribute SKDM to ${deviceJid}:`, err);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const phash = buildParticipantListHash(deviceJids);
|
|
558
|
+
const participantsNode = encodeNode("participants", {}, participantNodes);
|
|
559
|
+
const frankingNode = encodeNode("franking", {}, [
|
|
560
|
+
encodeNode("franking_tag", {}, result.frankingTag),
|
|
561
|
+
]);
|
|
562
|
+
const traceNode = encodeNode("trace", {}, [
|
|
563
|
+
encodeNode("request_id", {}, Buffer.from(randomUUID().replace(/-/g, ""), "hex")),
|
|
564
|
+
]);
|
|
565
|
+
const skmsgNode = encodeNode("enc", { v: "3", type: "skmsg" }, result.groupCiphertext);
|
|
566
|
+
|
|
567
|
+
const msgNode = encodeNode("message", { to: groupJid, type: "text", id: messageId, phash }, [
|
|
568
|
+
participantsNode,
|
|
569
|
+
frankingNode,
|
|
570
|
+
traceNode,
|
|
571
|
+
skmsgNode
|
|
572
|
+
]);
|
|
573
|
+
|
|
574
|
+
await this.e2eeSocket.sendFrame(marshalBinary(msgNode));
|
|
575
|
+
this.outgoingE2EECache.remember({
|
|
576
|
+
kind: "group",
|
|
577
|
+
chatJid: groupJid,
|
|
578
|
+
messageId,
|
|
579
|
+
messageType: "text",
|
|
580
|
+
messageApp: result.messageApp,
|
|
581
|
+
frankingTag: result.frankingTag,
|
|
582
|
+
createdAtMs: now(),
|
|
583
|
+
});
|
|
584
|
+
logger.info("ClientController", `E2EE Group message sent to ${groupJid} with ${participantNodes.length} devices`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private getSelfE2EEJid(): string {
|
|
588
|
+
const device = this.activeDeviceStore?.jidDevice ?? 0;
|
|
589
|
+
return `${this.userId}.${device}@msgr`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private isE2EEThreadId(threadId: string): boolean {
|
|
593
|
+
return /^\d+$/.test(threadId) || threadId.includes("@msgr") || threadId.includes("@g.us") || threadId.includes(".g.");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
public async sendReaction(input: SendReactionInput): Promise<void> { await this.messagingService.react(this.requireApi(), input); }
|
|
597
|
+
public async unsendMessage(messageId: string): Promise<void> { await this.messagingService.unsend(this.requireApi(), messageId); }
|
|
598
|
+
public async sendTyping(input: TypingInput): Promise<void> { await this.messagingService.sendTyping(this.requireApi(), input); }
|
|
599
|
+
public async markAsRead(input: MarkReadInput): Promise<void> { await this.messagingService.markAsRead(this.requireApi(), input); }
|
|
600
|
+
|
|
601
|
+
// --- E2EE Media Upload Config ---
|
|
602
|
+
private async getE2EEMediaUploadConfig(): Promise<MediaUploadConfig> {
|
|
603
|
+
if (this.e2eeUploadConfig && !this.isMediaUploadConfigExpired(this.e2eeUploadConfig)) {
|
|
604
|
+
return this.e2eeUploadConfig;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
logger.info("ClientController", "Fetching E2EE media upload auth via media_conn...");
|
|
608
|
+
this.e2eeUploadConfig = await this.e2eeHandler.getMediaUploadConfig();
|
|
609
|
+
this.e2eeService.setProvider(this.e2eeService.getClient(), this.e2eeUploadConfig);
|
|
610
|
+
return this.e2eeUploadConfig;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private async fetchMediaUploadConfigProactively(): Promise<void> {
|
|
614
|
+
if (!this.e2eeConnected) return;
|
|
615
|
+
try {
|
|
616
|
+
const config = await this.e2eeHandler.getMediaUploadConfig();
|
|
617
|
+
this.e2eeUploadConfig = config;
|
|
618
|
+
this.e2eeService.setProvider(this.e2eeService.getClient(), config);
|
|
619
|
+
logger.debug("ClientController", `Proactive media_conn fetched: host=${config.host}, auth=${config.auth ? `${config.auth.slice(0, 12)}...` : "(empty)"}`);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
logger.warn("ClientController", "Proactive media_conn fetch failed (will retry on first media send):", err);
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private isMediaUploadConfigExpired(config: MediaUploadConfig): boolean {
|
|
627
|
+
// Empty auth is always invalid - never cache it
|
|
628
|
+
if (!config.auth) return true;
|
|
629
|
+
const ttlSeconds = config.authTtl ?? config.ttl;
|
|
630
|
+
if (!config.fetchedAtMs || !ttlSeconds) return false;
|
|
631
|
+
const refreshSkewMs = 60_000;
|
|
632
|
+
return now() >= config.fetchedAtMs + ttlSeconds * 1000 - refreshSkewMs;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
public async sendE2EEImage(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
636
|
+
return this.sendE2EEMediaDM(input, "image", (fields) => this.e2eeService.getClient().buildImageMessage({ ...fields as unknown as MediaFields, caption: input.caption }));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
public async sendE2EEVideo(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
640
|
+
return this.sendE2EEMediaDM(input, "video", (fields) => this.e2eeService.getClient().buildVideoMessage({ ...fields as unknown as MediaFields, caption: input.caption }));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
public async sendE2EEAudio(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
644
|
+
return this.sendE2EEMediaDM(input, "audio", (fields) => this.e2eeService.getClient().buildAudioMessage(fields as unknown as MediaFields));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
public async sendE2EEFile(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
648
|
+
return this.sendE2EEMediaDM(input, "document", (fields) => this.e2eeService.getClient().buildDocumentMessage({ ...fields as unknown as MediaFields, fileName: input.fileName, caption: input.caption }));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Common E2EE media send for DM (fanout per-device encrypted message).
|
|
653
|
+
* All E2EE media messages use type="text" because the server cannot see the actual content.
|
|
654
|
+
*/
|
|
655
|
+
private async sendE2EEMediaDM(
|
|
656
|
+
input: SendMediaInput,
|
|
657
|
+
mediaType: MediaTypeKey,
|
|
658
|
+
buildMessage: (fields: Record<string, unknown>) => Buffer,
|
|
659
|
+
): Promise<Record<string, unknown>> {
|
|
660
|
+
if (!this.e2eeSocket) throw new Error("E2EE not connected");
|
|
661
|
+
if (input.threadId.includes("@g.us") || input.threadId.includes(".g.")) {
|
|
662
|
+
throw new Error(`E2EE group ${mediaType} send is not implemented yet`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const e2eeClient = this.e2eeService.getClient();
|
|
666
|
+
const selfJid = this.getSelfE2EEJid();
|
|
667
|
+
const toJid = normalizeDMThreadToJid(input.threadId);
|
|
668
|
+
const messageId = String(BigInt(Math.floor(Math.random() * 1e15)));
|
|
669
|
+
|
|
670
|
+
const uploadConfig = await this.getE2EEMediaUploadConfig();
|
|
671
|
+
|
|
672
|
+
const defaultMime = mediaType === "image" ? "image/png" : mediaType === "video" ? "video/mp4" : mediaType === "audio" ? "audio/ogg" : "application/octet-stream";
|
|
673
|
+
const media = await e2eeClient.encryptAndUploadMedia(
|
|
674
|
+
uploadConfig,
|
|
675
|
+
input.data,
|
|
676
|
+
mediaType,
|
|
677
|
+
input.mimeType || defaultMime,
|
|
678
|
+
async () => {
|
|
679
|
+
logger.info("ClientController", `Media upload 401, refreshing media_conn config...`);
|
|
680
|
+
const refreshed = await this.e2eeHandler.getMediaUploadConfig();
|
|
681
|
+
this.e2eeUploadConfig = refreshed;
|
|
682
|
+
this.e2eeService.setProvider(this.e2eeService.getClient(), refreshed);
|
|
683
|
+
return refreshed;
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const consumerApp = buildMessage(media.mediaFields);
|
|
688
|
+
const { messageApp, frankingTag } = e2eeClient.buildMessageApplication(
|
|
689
|
+
consumerApp,
|
|
690
|
+
input.replyToMessageId ? { id: input.replyToMessageId, senderJid: toJid } : undefined,
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const devicePayload = e2eeClient.buildMessageTransport({ messageApp });
|
|
694
|
+
const selfDevicePayload = e2eeClient.buildMessageTransport({
|
|
695
|
+
messageApp,
|
|
696
|
+
dsm: { destinationJid: toJid, phash: "" },
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const participantNodes: Buffer[] = [];
|
|
700
|
+
const deviceJids = uniqueJids(await this.e2eeHandler.getDeviceList([toJid, toBareMessengerJid(selfJid)]));
|
|
701
|
+
if (deviceJids.length === 0) {
|
|
702
|
+
logger.warn("ClientController", `No E2EE devices discovered for ${toJid}; sending empty participant list`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const deviceJid of deviceJids) {
|
|
706
|
+
if (sameMessengerDevice(deviceJid, selfJid)) continue;
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
if (!(await e2eeClient.hasSession(deviceJid))) {
|
|
710
|
+
logger.info("ClientController", `Establishing new session with ${deviceJid}`);
|
|
711
|
+
const bundle = await this.e2eeHandler.getPreKeyBundle(deviceJid);
|
|
712
|
+
await e2eeClient.establishSession(deviceJid, bundle);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const payload = sameMessengerUser(deviceJid, selfJid)
|
|
716
|
+
? selfDevicePayload
|
|
717
|
+
: devicePayload;
|
|
718
|
+
const encrypted = await e2eeClient.encryptDevicePayload(deviceJid, selfJid, payload);
|
|
719
|
+
|
|
720
|
+
participantNodes.push(encodeNode("to", { jid: deviceJid }, [
|
|
721
|
+
encodeNode("enc", { v: "3", type: encrypted.type }, encrypted.ciphertext),
|
|
722
|
+
]));
|
|
723
|
+
} catch (err) {
|
|
724
|
+
logger.error("ClientController", `Failed to encrypt E2EE ${mediaType} fanout to ${deviceJid}:`, err);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const msgNode = encodeNode("message", { to: toJid, type: "text", id: messageId }, [
|
|
729
|
+
encodeNode("participants", {}, participantNodes),
|
|
730
|
+
encodeNode("franking", {}, [
|
|
731
|
+
encodeNode("franking_tag", {}, frankingTag),
|
|
732
|
+
]),
|
|
733
|
+
encodeNode("trace", {}, [
|
|
734
|
+
encodeNode("request_id", {}, Buffer.from(randomUUID().replace(/-/g, ""), "hex")),
|
|
735
|
+
]),
|
|
736
|
+
]);
|
|
737
|
+
|
|
738
|
+
await this.e2eeSocket.sendFrame(marshalBinary(msgNode));
|
|
739
|
+
this.outgoingE2EECache.remember({
|
|
740
|
+
kind: "dm",
|
|
741
|
+
chatJid: toJid,
|
|
742
|
+
messageId,
|
|
743
|
+
messageType: mediaType,
|
|
744
|
+
messageApp,
|
|
745
|
+
frankingTag,
|
|
746
|
+
createdAtMs: now(),
|
|
747
|
+
});
|
|
748
|
+
logger.info("ClientController", `E2EE ${mediaType} sent to ${toJid} with ${participantNodes.length} devices`);
|
|
749
|
+
return { messageId, timestampMs: now(), directPath: media.directPath };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
public async sendImage(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
753
|
+
if (this.e2eeConnected && this.isE2EEThreadId(input.threadId)) {
|
|
754
|
+
return this.sendE2EEImage(input);
|
|
755
|
+
}
|
|
756
|
+
return this.mediaService.sendImage(this.requireApi(), input);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
public async sendVideo(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
760
|
+
if (this.e2eeConnected && this.isE2EEThreadId(input.threadId)) {
|
|
761
|
+
return this.sendE2EEVideo(input);
|
|
762
|
+
}
|
|
763
|
+
return this.mediaService.sendVideo(this.requireApi(), input);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
public async sendAudio(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
767
|
+
if (this.e2eeConnected && this.isE2EEThreadId(input.threadId)) {
|
|
768
|
+
return this.sendE2EEAudio(input);
|
|
769
|
+
}
|
|
770
|
+
return this.mediaService.sendAudio(this.requireApi(), input);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
public async sendFile(input: SendMediaInput): Promise<Record<string, unknown>> {
|
|
774
|
+
if (this.e2eeConnected && this.isE2EEThreadId(input.threadId)) {
|
|
775
|
+
return this.sendE2EEFile(input);
|
|
776
|
+
}
|
|
777
|
+
return this.mediaService.sendFile(this.requireApi(), input);
|
|
778
|
+
}
|
|
779
|
+
public async sendSticker(input: SendStickerInput) { return this.mediaService.sendSticker(this.requireApi(), input); }
|
|
780
|
+
public async downloadMedia(input: DownloadMediaInput) { return this.mediaService.downloadMedia(input); }
|
|
781
|
+
|
|
782
|
+
public async muteThread(input: MuteThreadInput) { await this.mediaService.muteThread(this.requireApi(), input); }
|
|
783
|
+
public async renameThread(input: RenameThreadInput) { await this.mediaService.renameThread(this.requireApi(), input); }
|
|
784
|
+
public async setGroupPhoto(input: SetGroupPhotoInput) { await this.mediaService.setGroupPhoto(this.requireApi(), input); }
|
|
785
|
+
public async deleteThread(input: DeleteThreadInput) { await this.mediaService.deleteThread(this.requireApi(), input); }
|
|
786
|
+
public async createThread(input: CreateThreadInput) { return this.mediaService.createThread(this.requireApi(), input); }
|
|
787
|
+
|
|
788
|
+
public async searchUsers(input: SearchUsersInput) { return this.mediaService.searchUsers(this.requireApi(), input); }
|
|
789
|
+
public async getUserInfo(input: GetUserInfoInput) { return this.mediaService.getUserInfo(this.requireApi(), input); }
|
|
790
|
+
|
|
791
|
+
public async getThreadList(input: GetThreadListInput) { return this.threadService.getThreadList(this.requireApi(), input); }
|
|
792
|
+
public async getThreadHistory(input: GetThreadHistoryInput) { return this.threadService.getThreadHistory(this.requireApi(), input); }
|
|
793
|
+
public async forwardAttachment(input: ForwardAttachmentInput) { await this.threadService.forwardAttachment(this.requireApi(), input); }
|
|
794
|
+
public async createPoll(input: CreatePollInput) { await this.threadService.createPoll(this.requireApi(), input); }
|
|
795
|
+
public async editMessage(input: EditMessageInput) { return this.threadService.editMessage(this.requireApi(), input); }
|
|
796
|
+
public async addGroupMember(input: AddGroupMemberInput) { await this.threadService.addGroupMember(this.requireApi(), input); }
|
|
797
|
+
public async removeGroupMember(input: RemoveGroupMemberInput) { await this.threadService.removeGroupMember(this.requireApi(), input); }
|
|
798
|
+
public async changeAdminStatus(input: ChangeAdminStatusInput) { await this.threadService.changeAdminStatus(this.requireApi(), input); }
|
|
799
|
+
public async getFriendsList() { return this.threadService.getFriendsList(this.requireApi()); }
|
|
800
|
+
|
|
801
|
+
private requireApi(): FCAApi {
|
|
802
|
+
if (!this.api) throw new Error("Client is not connected");
|
|
803
|
+
return this.api;
|
|
804
|
+
}
|
|
805
|
+
}
|