max-account-api 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/dist/client.js ADDED
@@ -0,0 +1,1601 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { Opcode } from './opcodes.js';
4
+ import { Transport } from './transport.js';
5
+ import { DEFAULT_USER_AGENT, commitPasswordChange, completeQrPasswordLogin, getPasswordInfo, hello, loginViaQr, loginWithToken, logout as logoutOp, sendEmailCode, setPasswordHint, startAuthTrack, validatePassword, verifyEmailCode, verifyPassword, } from './auth.js';
6
+ import { RawTransport } from './raw-transport.js';
7
+ import { qrBindWebSession } from './qr-bind.js';
8
+ import { sendPhoneAuthCode, verifyPhoneAuthCode } from './auth.js';
9
+ import { FileSessionStore, MemorySessionStore } from './storage.js';
10
+ /**
11
+ * High-level MAX account client.
12
+ *
13
+ * ```ts
14
+ * const client = new MaxClient();
15
+ * client.on('message', (m) => console.log(m.fromId, m.text));
16
+ * await client.start();
17
+ * await client.sendMessage(0, 'hello, saved messages!');
18
+ * ```
19
+ */
20
+ export class MaxClient extends EventEmitter {
21
+ url;
22
+ session;
23
+ userAgent;
24
+ printQr;
25
+ printCredentialsAfterLogin;
26
+ autoRead;
27
+ chatsCount;
28
+ resolvePassword;
29
+ transport;
30
+ state = null;
31
+ me = null;
32
+ chats = new Map();
33
+ constructor(opts = {}) {
34
+ super();
35
+ this.url = opts.url ?? 'wss://ws-api.oneme.ru/websocket';
36
+ this.session = MaxClient.resolveSession(opts);
37
+ this.userAgent = opts.userAgent ?? DEFAULT_USER_AGENT;
38
+ this.printQr = opts.printQr ?? true;
39
+ this.printCredentialsAfterLogin = opts.printCredentialsAfterLogin ?? true;
40
+ this.autoRead = opts.autoRead ?? false;
41
+ this.chatsCount = opts.chatsCount ?? 40;
42
+ if (opts.resolvePassword)
43
+ this.resolvePassword = opts.resolvePassword;
44
+ this.transport = new Transport({
45
+ url: this.url,
46
+ headers: {
47
+ 'User-Agent': this.userAgent.headerUserAgent,
48
+ ...(this.userAgent.deviceType === 'WEB' ? { Origin: 'https://web.max.ru' } : {}),
49
+ },
50
+ ...(opts.pingIntervalMs !== undefined ? { pingIntervalMs: opts.pingIntervalMs } : {}),
51
+ ...(opts.requestTimeoutMs !== undefined ? { requestTimeoutMs: opts.requestTimeoutMs } : {}),
52
+ });
53
+ this.transport.on('error', (err) => this.emit('error', err));
54
+ this.transport.on('close', (code, reason) => this.emit('close', code, reason));
55
+ this.transport.on('reconnect', (n) => {
56
+ this.emit('reconnect', n);
57
+ // After reconnect, re-handshake silently using stored token.
58
+ void this.rehandshake().catch((err) => this.emit('error', err));
59
+ });
60
+ this.transport.on('raw', (f) => this.emit('raw', f));
61
+ this.transport.on('push', (f) => this.handlePush(f));
62
+ }
63
+ static resolveSession(opts) {
64
+ if (opts.session)
65
+ return opts.session;
66
+ if (opts.deviceId || opts.loginToken) {
67
+ const seed = {};
68
+ if (opts.deviceId)
69
+ seed.deviceId = opts.deviceId;
70
+ if (opts.loginToken)
71
+ seed.loginToken = opts.loginToken;
72
+ return new MemorySessionStore(seed);
73
+ }
74
+ return new FileSessionStore(opts.sessionFile);
75
+ }
76
+ /** Connect, handshake, login (via QR if no token stored), and emit `ready`. */
77
+ async start() {
78
+ await this.transport.connect();
79
+ const sess = await this.session.load();
80
+ await hello(this.transport, sess.deviceId, this.userAgent);
81
+ let token = sess.loginToken;
82
+ if (!token) {
83
+ const qrRes = await loginViaQr(this.transport, {
84
+ emitQr: (info) => this.emit('qr', info),
85
+ printToTerminal: this.printQr,
86
+ ...(this.resolvePassword ? { resolvePassword: this.resolvePassword } : {}),
87
+ });
88
+ const loginToken = qrRes.tokenAttrs.LOGIN?.token;
89
+ if (!loginToken) {
90
+ throw new Error('QR login finished without a LOGIN token (2FA flow incomplete).');
91
+ }
92
+ token = loginToken;
93
+ if (qrRes.profile)
94
+ this.emit('login', qrRes.profile.contact);
95
+ const seed = { ...sess, loginToken: token };
96
+ if (qrRes.profile)
97
+ seed.ownerId = qrRes.profile.contact.id;
98
+ await this.session.save(seed);
99
+ if (this.printCredentialsAfterLogin) {
100
+ // eslint-disable-next-line no-console
101
+ console.log('\n[max-account-api] Сохраните эти данные, чтобы переиспользовать сессию:\n' +
102
+ ` deviceId: ${sess.deviceId}\n` +
103
+ ` loginToken: ${token}\n` +
104
+ 'Передайте их в new MaxClient({ deviceId, loginToken }) при следующем запуске.\n');
105
+ }
106
+ }
107
+ const login = await loginWithToken(this.transport, token, { chatsCount: this.chatsCount });
108
+ this.state = login;
109
+ this.me = login.profile.contact;
110
+ for (const c of login.chats)
111
+ this.chats.set(c.id, c);
112
+ // Persist refreshed token (server may rotate it).
113
+ if (login.token && login.token !== token) {
114
+ await this.session.save({ ...sess, loginToken: login.token, ownerId: this.me.id });
115
+ }
116
+ this.emit('ready');
117
+ }
118
+ /** Disconnect and stop reconnects. */
119
+ async stop() {
120
+ this.transport.close();
121
+ }
122
+ /** Logged-in account info. Available after `ready`. */
123
+ getMe() {
124
+ return this.me;
125
+ }
126
+ /** Chats snapshot (last seen by the server during login). */
127
+ getChats() {
128
+ return Array.from(this.chats.values());
129
+ }
130
+ /**
131
+ * Send a text message to a chat. Use `chatId = 0` for Saved Messages.
132
+ */
133
+ async sendMessage(chatId, text, opts = {}) {
134
+ const cid = -Date.now();
135
+ const req = {
136
+ chatId,
137
+ message: { text, cid, elements: [], attaches: [], ...opts },
138
+ notify: true,
139
+ };
140
+ const res = await this.transport.request(Opcode.SEND_MESSAGE, req);
141
+ return res.message;
142
+ }
143
+ /** Reply helper — sends to the same chat the incoming message came from. */
144
+ async reply(incoming, text) {
145
+ return this.sendMessage(incoming.chatId, text);
146
+ }
147
+ /**
148
+ * Send a quoted reply to a specific message. Server attaches the original
149
+ * message envelope automatically based on `messageId`.
150
+ */
151
+ async sendReply(chatId, replyToMessageId, text) {
152
+ return this.sendMessage(chatId, text, {
153
+ link: { type: 'REPLY', messageId: replyToMessageId },
154
+ });
155
+ }
156
+ /**
157
+ * Set (or replace) your reaction on a message. Re-calling with a different
158
+ * emoji upserts — there is no separate "change" call.
159
+ */
160
+ async setReaction(chatId, messageId, emoji) {
161
+ const req = {
162
+ chatId,
163
+ messageId,
164
+ reaction: { reactionType: 'EMOJI', id: emoji },
165
+ };
166
+ const res = await this.transport.request(Opcode.REACTION_SET, req);
167
+ return res.reactionInfo;
168
+ }
169
+ /** Fetch reactions for a batch of messages in one chat. */
170
+ async getReactions(chatId, messageIds) {
171
+ const req = { chatId, messageIds };
172
+ const res = await this.transport.request(Opcode.REACTIONS_GET, req);
173
+ return res.messagesReactions;
174
+ }
175
+ /**
176
+ * Detailed reaction listing for a single message. `count` is the number of
177
+ * reactor entries to fetch (default 100 in the web client).
178
+ */
179
+ async listReactions(chatId, messageId, count = 100) {
180
+ const req = { chatId, messageId, count };
181
+ const res = await this.transport.request(Opcode.REACTIONS_LIST, req);
182
+ return res.reactionInfo;
183
+ }
184
+ /** Remove your reaction from a message. */
185
+ async removeReaction(chatId, messageId) {
186
+ const req = { chatId, messageId };
187
+ const res = await this.transport.request(Opcode.REACTION_REMOVE, req);
188
+ return res.reactionInfo;
189
+ }
190
+ /**
191
+ * Delete one or more messages.
192
+ * - `forMe=true` → delete only on this device (own messages, any time).
193
+ * - `forMe=false` → delete for all participants. Requires admin rights for
194
+ * foreign messages in groups, or being the author within edit window.
195
+ */
196
+ async deleteMessages(chatId, messageIds, forMe = false) {
197
+ const req = { chatId, messageIds, forMe };
198
+ return this.transport.request(Opcode.DELETE_MESSAGES, req);
199
+ }
200
+ /** Edit your own message. 7-day window per server config (`edit-timeout`). */
201
+ async editMessage(chatId, messageId, text, opts = {}) {
202
+ const req = {
203
+ chatId,
204
+ messageId,
205
+ text,
206
+ elements: opts.elements ?? [],
207
+ attachments: opts.attachments ?? [],
208
+ };
209
+ const res = await this.transport.request(Opcode.EDIT_MESSAGE, req);
210
+ return res.message;
211
+ }
212
+ /**
213
+ * Forward a message into another chat. Pass `toChatId=0` for Saved Messages.
214
+ * Optional `comment` adds a leading text message before the forward.
215
+ */
216
+ async forwardMessage(toChatId, sourceChatId, messageId, comment) {
217
+ if (comment !== undefined) {
218
+ await this.sendMessage(toChatId, comment);
219
+ }
220
+ const cid = -Date.now();
221
+ const req = {
222
+ chatId: toChatId,
223
+ message: {
224
+ cid,
225
+ text: '',
226
+ attaches: [],
227
+ elements: [],
228
+ link: { type: 'FORWARD', messageId, chatId: sourceChatId },
229
+ },
230
+ notify: true,
231
+ };
232
+ const res = await this.transport.request(Opcode.SEND_MESSAGE, req);
233
+ return res.message;
234
+ }
235
+ /**
236
+ * Create a new group chat with given participants. Returns the SendMessage
237
+ * response: `chatId` is the new group's id, `chat` carries the freshly
238
+ * created group snapshot.
239
+ */
240
+ async createGroup(title, userIds, opts = {}) {
241
+ const cid = Date.now();
242
+ const attach = {
243
+ _type: 'CONTROL',
244
+ event: 'new',
245
+ chatType: opts.chatType ?? 'CHAT',
246
+ title,
247
+ userIds,
248
+ };
249
+ const req = {
250
+ message: { cid, attaches: [attach] },
251
+ notify: true,
252
+ };
253
+ const res = await this.transport.request(Opcode.SEND_MESSAGE, req);
254
+ if (res.chat)
255
+ this.chats.set(res.chat.id, res.chat);
256
+ return res;
257
+ }
258
+ /** Add members to a group. */
259
+ async addGroupParticipants(chatId, userIds, showHistory = true) {
260
+ const req = { chatId, userIds, operation: 'add', showHistory };
261
+ const res = await this.transport.request(Opcode.GROUP_PARTICIPANTS, req);
262
+ this.chats.set(res.chat.id, res.chat);
263
+ return res.chat;
264
+ }
265
+ /**
266
+ * Remove a member from the group. `cleanMsgPeriod` (seconds) wipes that much
267
+ * of the user's recent history, 0 keeps everything.
268
+ */
269
+ async removeGroupParticipant(chatId, userId, cleanMsgPeriod = 0) {
270
+ const req = {
271
+ chatId,
272
+ userIds: [userId],
273
+ operation: 'remove',
274
+ cleanMsgPeriod,
275
+ };
276
+ const res = await this.transport.request(Opcode.GROUP_PARTICIPANTS, req);
277
+ this.chats.set(res.chat.id, res.chat);
278
+ return res.chat;
279
+ }
280
+ /** Promote a user to admin. `permissions` is a bitmask, 255 = full admin. */
281
+ async setGroupAdmin(chatId, userId, permissions = 255) {
282
+ const req = {
283
+ chatId,
284
+ userIds: [userId],
285
+ type: 'ADMIN',
286
+ operation: 'add',
287
+ permissions,
288
+ };
289
+ const res = await this.transport.request(Opcode.GROUP_PARTICIPANTS, req);
290
+ this.chats.set(res.chat.id, res.chat);
291
+ return res.chat;
292
+ }
293
+ /** Demote an admin back to a regular member. */
294
+ async removeGroupAdmin(chatId, userId) {
295
+ const req = {
296
+ chatId,
297
+ userIds: [userId],
298
+ type: 'ADMIN',
299
+ operation: 'remove',
300
+ };
301
+ const res = await this.transport.request(Opcode.GROUP_PARTICIPANTS, req);
302
+ this.chats.set(res.chat.id, res.chat);
303
+ return res.chat;
304
+ }
305
+ /**
306
+ * Update group settings (op 55). Pass any subset:
307
+ * `options` — boolean flags (ALL_CAN_PIN_MESSAGE, ONLY_ADMIN_CAN_CALL, …).
308
+ * `theme` / `description` — rename / set bio.
309
+ * `pinMessageId` / `notifyPin` — pin a message.
310
+ */
311
+ async updateChatSettings(chatId, patch) {
312
+ const req = { chatId, ...patch };
313
+ const res = await this.transport.request(Opcode.CHAT_SETTINGS, req);
314
+ this.chats.set(res.chat.id, res.chat);
315
+ return res.chat;
316
+ }
317
+ /** Convenience: rename group + optionally update description. */
318
+ async renameGroup(chatId, title, description) {
319
+ const patch = { theme: title };
320
+ if (description !== undefined)
321
+ patch.description = description;
322
+ return this.updateChatSettings(chatId, patch);
323
+ }
324
+ /** Set group permission flags. Merges with existing options server-side. */
325
+ async setGroupOptions(chatId, options) {
326
+ return this.updateChatSettings(chatId, { options });
327
+ }
328
+ /** Pin a message in chat. `notify=true` posts a service message. */
329
+ async pinMessage(chatId, messageId, notify = false) {
330
+ return this.updateChatSettings(chatId, { pinMessageId: messageId, notifyPin: notify });
331
+ }
332
+ /**
333
+ * Configure per-chat reaction policy.
334
+ * - disable: `setChatReactionsPolicy(id, { value: false })`
335
+ * - allow N: `setChatReactionsPolicy(id, { value: true, included: true, reactionIds: ['👍','❤️'], count: 2 })`
336
+ * - allow all:`setChatReactionsPolicy(id, { value: true, included: false, count: 2 })`
337
+ */
338
+ async setChatReactionsPolicy(chatId, cfg) {
339
+ const req = { chatId, ...cfg };
340
+ const res = await this.transport.request(Opcode.CHAT_REACTIONS_SETTINGS, req);
341
+ return res.chatReactionsSettings;
342
+ }
343
+ /** Chats you share with the given user. */
344
+ async getCommonChats(userId) {
345
+ const req = { userIds: [userId] };
346
+ return this.transport.request(Opcode.COMMON_CHATS, req);
347
+ }
348
+ /** Search messages inside a chat by text. */
349
+ async searchMessages(chatId, query, count = 30) {
350
+ const req = { chatId, query, count };
351
+ const res = await this.transport.request(Opcode.MESSAGE_SEARCH, req);
352
+ return res.result;
353
+ }
354
+ /**
355
+ * Mark a chat as "opened" / focused. Web client sends this around search
356
+ * sessions and chat focus changes; server replies with empty payload.
357
+ */
358
+ async openChat(chatId) {
359
+ await this.transport.request(Opcode.CHAT_OPEN, { chatId });
360
+ }
361
+ /** Mark a message as read. */
362
+ async readMessage(chatId, messageId, mark = Date.now()) {
363
+ const req = { type: 'READ_MESSAGE', chatId, messageId, mark };
364
+ await this.transport.request(Opcode.CHAT_EVENT, req);
365
+ }
366
+ /** Send "typing…" presence to a chat. Repeats are needed to keep it active. */
367
+ async sendTyping(chatId, type = 'TEXT') {
368
+ await this.transport.request(Opcode.TYPING, { chatId, type });
369
+ }
370
+ /** Fetch message history from a chat. */
371
+ async getHistory(chatId, opts = {}) {
372
+ const req = {
373
+ chatId,
374
+ from: opts.from ?? Date.now(),
375
+ forward: opts.forward ?? 0,
376
+ backward: opts.backward ?? 30,
377
+ getMessages: true,
378
+ };
379
+ const res = await this.transport.request(Opcode.HISTORY_GET, req);
380
+ return res.messages;
381
+ }
382
+ /** Fetch chats by ids. */
383
+ async getChatsByIds(chatIds) {
384
+ const res = await this.transport.request(Opcode.CHATS_GET, { chatIds });
385
+ for (const c of res.chats)
386
+ this.chats.set(c.id, c);
387
+ return res.chats;
388
+ }
389
+ /** Subscribe / unsubscribe to push events inside a chat. */
390
+ async subscribeChat(chatId, subscribe) {
391
+ const req = { chatId, subscribe };
392
+ await this.transport.request(Opcode.CHAT_SUBSCRIBE, req);
393
+ }
394
+ /** Escape hatch: send any opcode and get its response. */
395
+ raw(opcode, payload) {
396
+ return this.transport.request(opcode, payload);
397
+ }
398
+ // ====================================================================
399
+ // Profile (op 16)
400
+ // ====================================================================
401
+ /** Update own profile fields. Pass any subset. */
402
+ async updateProfile(patch) {
403
+ const res = await this.transport.request(Opcode.PROFILE_UPDATE, patch);
404
+ this.me = res.profile.contact;
405
+ return this.me;
406
+ }
407
+ /** Convenience: rename own account. */
408
+ async setProfileName(firstName, lastName = '') {
409
+ return this.updateProfile({ firstName, lastName });
410
+ }
411
+ /** Set profile bio. */
412
+ async setProfileDescription(description) {
413
+ return this.updateProfile({ description });
414
+ }
415
+ /**
416
+ * Set own avatar from a previously uploaded photo. Get `photoToken` from
417
+ * `uploadPhotoBytes()` (high-level) or `requestPhotoUploadUrl()` + HTTP POST.
418
+ */
419
+ async setProfileAvatar(photoToken, firstName = '', lastName = '') {
420
+ return this.updateProfile({ firstName, lastName, photoToken, avatarType: 'USER_AVATAR' });
421
+ }
422
+ // ====================================================================
423
+ // Settings (op 22)
424
+ // ====================================================================
425
+ /** Raw settings setter — pass any subset of `settings.user` / `settings.chats`. */
426
+ async updateSettings(patch) {
427
+ return this.transport.request(Opcode.SETTINGS, { settings: patch });
428
+ }
429
+ /**
430
+ * Mute / unmute a chat.
431
+ * - `muteUntilMs` omitted or `-1` → mute forever
432
+ * - `0` → unmute
433
+ * - timestamp (ms) → mute until that moment
434
+ */
435
+ async muteChat(chatId, muteUntilMs = -1) {
436
+ return this.updateSettings({ chats: { [String(chatId)]: { dontDisturbUntil: muteUntilMs } } });
437
+ }
438
+ /** Convenience: unmute chat. */
439
+ async unmuteChat(chatId) {
440
+ return this.muteChat(chatId, 0);
441
+ }
442
+ /** Update privacy / push / safety toggles on the user level. */
443
+ async setUserSettings(user) {
444
+ return this.updateSettings({ user });
445
+ }
446
+ // ====================================================================
447
+ // Sessions (op 96)
448
+ // ====================================================================
449
+ /** List active login sessions across devices. */
450
+ async listSessions() {
451
+ const res = await this.transport.request(Opcode.SESSIONS_LIST, {});
452
+ return res.sessions;
453
+ }
454
+ // ====================================================================
455
+ // Contacts (op 34, 36, 41, 46)
456
+ // ====================================================================
457
+ /** Low-level contact action. Use the named helpers below for clarity. */
458
+ async contactAction(req) {
459
+ return this.transport.request(Opcode.CONTACT_ACTION, req);
460
+ }
461
+ /** Remove a saved contact. */
462
+ async removeContact(contactId) {
463
+ await this.contactAction({ contactId, action: 'REMOVE' });
464
+ }
465
+ /** Rename a contact in your address book (does not affect their profile). */
466
+ async renameContact(contactId, firstName, lastName = '') {
467
+ const res = await this.contactAction({ contactId, action: 'UPDATE', firstName, lastName });
468
+ return res.contact;
469
+ }
470
+ async blockContact(contactId) {
471
+ await this.contactAction({ contactId, action: 'BLOCK' });
472
+ }
473
+ async unblockContact(contactId) {
474
+ await this.contactAction({ contactId, action: 'UNBLOCK' });
475
+ }
476
+ /**
477
+ * List your contacts filtered by status. `status:'BLOCKED'` is the block list.
478
+ * Paginate with `from` (offset).
479
+ */
480
+ async listContactsByStatus(status, opts = {}) {
481
+ const req = { status, count: opts.count ?? 100, from: opts.from ?? 0 };
482
+ const res = await this.transport.request(Opcode.CONTACTS_LIST_BY_STATUS, req);
483
+ return res.contacts;
484
+ }
485
+ /** Get the list of users you've blocked. */
486
+ async listBlockedContacts(opts = {}) {
487
+ return this.listContactsByStatus('BLOCKED', opts);
488
+ }
489
+ /**
490
+ * Add a contact by phone number (E.164). Throws if the phone is not registered.
491
+ * `new=false` in response means the contact already existed.
492
+ */
493
+ async addContactByPhone(phone, firstName, lastName) {
494
+ const req = { phone };
495
+ if (firstName !== undefined)
496
+ req.firstName = firstName;
497
+ if (lastName !== undefined)
498
+ req.lastName = lastName;
499
+ return this.transport.request(Opcode.CONTACT_ADD_BY_PHONE, req);
500
+ }
501
+ /** Batch-fetch contact info by ids (op 32). */
502
+ async getContactsInfo(contactIds) {
503
+ const res = await this.transport.request(Opcode.CONTACT_INFO, { contactIds });
504
+ return res.contacts;
505
+ }
506
+ /** Convenience: fetch a single contact by id. */
507
+ async getContactInfo(contactId) {
508
+ const [c] = await this.getContactsInfo([contactId]);
509
+ return c;
510
+ }
511
+ /** Get last-seen / online status for one or more contacts (op 35). */
512
+ async getPresence(contactIds) {
513
+ return this.transport.request(Opcode.PRESENCE_GET, { contactIds });
514
+ }
515
+ /**
516
+ * Subscribe to presence push updates for a user (op 177). Server then sends
517
+ * op 132 frames whenever their `seen`/`status` change. Pass `time:0` for
518
+ * "from now"; positive timestamp resumes from a previous cursor.
519
+ */
520
+ async subscribePresence(userId, time = 0) {
521
+ // FIXME(decompilation): the pre-decompile alias `PRESENCE_SUBSCRIBE: 177`
522
+ // is wrong — op 177 is canonically `DRAFT_DISCARD` in the Android enum.
523
+ // The correct subscribe opcode hasn't been re-identified from source yet;
524
+ // keeping the call as-is so existing behaviour is unchanged until we
525
+ // pinpoint the real op.
526
+ await this.transport.request(Opcode.PRESENCE_SUBSCRIBE, { userId, time });
527
+ }
528
+ /** Resolve a phone number to a contact without saving. */
529
+ async lookupContactByPhone(phone) {
530
+ const req = { phone };
531
+ const res = await this.transport.request(Opcode.CONTACT_LOOKUP_BY_PHONE, req);
532
+ return res.contact;
533
+ }
534
+ // ====================================================================
535
+ // History / chat browsing (op 51, 54, 58, 59, 74)
536
+ // ====================================================================
537
+ /**
538
+ * Fetch surrounding messages filtered by attach type. Used by photo/file
539
+ * viewers to walk media in the chat.
540
+ */
541
+ async getHistoryByAttach(chatId, messageId, attachTypes, opts = {}) {
542
+ const req = {
543
+ chatId,
544
+ messageId,
545
+ attachTypes,
546
+ forward: opts.forward ?? 25,
547
+ backward: opts.backward ?? 25,
548
+ };
549
+ const res = await this.transport.request(Opcode.HISTORY_BY_ATTACH, req);
550
+ return res.messages;
551
+ }
552
+ /**
553
+ * Delete (or wipe) a chat. `forAll=true` removes for every participant.
554
+ * `lastEventTimeMs` defaults to "now" (deletes everything up to current).
555
+ */
556
+ async deleteChat(chatId, forAll = false, lastEventTimeMs = Date.now()) {
557
+ await this.transport.request(Opcode.CHAT_DELETE, {
558
+ chatId,
559
+ forAll,
560
+ lastEventTime: lastEventTimeMs,
561
+ });
562
+ this.chats.delete(chatId);
563
+ }
564
+ /** Leave a group / channel. Server posts a CONTROL `leave` message. */
565
+ async leaveChat(chatId) {
566
+ const req = { chatId };
567
+ const res = await this.transport.request(Opcode.CHAT_LEAVE, req);
568
+ return res.message;
569
+ }
570
+ /** Paginated participants list. Use `type:'ADMIN'` to list admins only. */
571
+ async listMembers(chatId, opts = {}) {
572
+ const req = {
573
+ chatId,
574
+ type: opts.type ?? 'MEMBER',
575
+ marker: opts.marker ?? 0,
576
+ count: opts.count ?? 50,
577
+ };
578
+ const res = await this.transport.request(Opcode.MEMBERS_LIST, req);
579
+ return res.members;
580
+ }
581
+ /** View counts for channel posts. */
582
+ async getMessageStats(chatId, messageIds) {
583
+ const req = { chatId, messageIds };
584
+ const res = await this.transport.request(Opcode.MESSAGE_STATS, req);
585
+ return res.stats;
586
+ }
587
+ // ====================================================================
588
+ // Global search (op 60, 68)
589
+ // ====================================================================
590
+ /** Cross-section global search. Paginate via `marker`. */
591
+ async globalSearch(query, opts = {}) {
592
+ const req = { query, count: opts.count ?? 30 };
593
+ if (opts.marker !== undefined)
594
+ req.marker = opts.marker;
595
+ return this.transport.request(Opcode.GLOBAL_SEARCH, req);
596
+ }
597
+ /** Single-section search (returns full chat objects in a flat array). */
598
+ async globalSearchByType(query, type, count = 30) {
599
+ const req = { query, count, type };
600
+ const res = await this.transport.request(Opcode.GLOBAL_SEARCH_BY_TYPE, req);
601
+ return res.result;
602
+ }
603
+ // ====================================================================
604
+ // Calls (op 78)
605
+ // ====================================================================
606
+ /**
607
+ * Initiate a 1:1 audio or video call to a single user. Returns the WebRTC
608
+ * endpoint info (`internalCallerParams` parsed from JSON) — connect to
609
+ * that WebSocket separately to actually carry media.
610
+ *
611
+ * NOTE: this opens the call signalling slot only; full call media goes over
612
+ * `wss://videowebrtc.okcdn.ru/ws2` and is out of scope of this library.
613
+ */
614
+ async startCall(calleeId, opts = {}) {
615
+ const conversationId = opts.conversationId ?? randomUUID();
616
+ const sess = await this.session.load();
617
+ const internal = {
618
+ deviceId: opts.deviceId ?? sess.deviceId,
619
+ sdkVersion: '1.1',
620
+ clientAppKey: 'CNHIJPLGDIHBABABA',
621
+ platform: 'WEB',
622
+ protocolVersion: 5,
623
+ domainId: '',
624
+ capabilities: '2A03F',
625
+ };
626
+ const req = {
627
+ conversationId,
628
+ calleeIds: [calleeId],
629
+ internalParams: JSON.stringify(internal),
630
+ isVideo: opts.isVideo ?? false,
631
+ };
632
+ const res = await this.transport.request(Opcode.CALL_START, req);
633
+ let caller;
634
+ try {
635
+ caller = JSON.parse(res.internalCallerParams);
636
+ }
637
+ catch {
638
+ caller = { id: { internal: '', external: '' }, endpoint: '', clientType: '' };
639
+ }
640
+ return { conversationId: res.conversationId, caller, raw: res };
641
+ }
642
+ // ====================================================================
643
+ // File / photo / sticker (op 29, 80, 83, 87, 88)
644
+ // ====================================================================
645
+ /** Allocate one or more photo upload slots. Returns the HTTPS upload URL. */
646
+ async requestPhotoUploadUrl(count = 1) {
647
+ const req = { count };
648
+ const res = await this.transport.request(Opcode.PHOTO_UPLOAD_URL, req);
649
+ return res.url;
650
+ }
651
+ /** Allocate file upload slot(s). Returns `{url, fileId, token}` per slot. */
652
+ async requestFileUploadUrl(count = 1) {
653
+ const req = { count };
654
+ const res = await this.transport.request(Opcode.FILE_UPLOAD_URL, req);
655
+ return res.info;
656
+ }
657
+ /** Resolve a CDN download URL for a file attach. */
658
+ async getFileDownloadUrl(fileId, chatId, messageId) {
659
+ const req = { fileId, chatId, messageId };
660
+ return this.transport.request(Opcode.FILE_DOWNLOAD_URL, req);
661
+ }
662
+ /** Resolve playable URLs for an external (e.g. OK / VK) video attachment. */
663
+ async resolveExternalVideo(videoId, token, chatId, messageId) {
664
+ const req = { videoId, token, chatId, messageId };
665
+ return this.transport.request(Opcode.EXTERNAL_VIDEO_RESOLVE, req);
666
+ }
667
+ /**
668
+ * Upload a photo end-to-end and return its photoToken. Combines op 80 with
669
+ * the HTTPS multipart POST. Uses global `fetch` (Node 18+).
670
+ */
671
+ async uploadPhotoBytes(file) {
672
+ const url = await this.requestPhotoUploadUrl(1);
673
+ const { uploadPhoto, firstPhotoToken } = await import('./media.js');
674
+ const res = await uploadPhoto(url, file);
675
+ return firstPhotoToken(res);
676
+ }
677
+ /**
678
+ * Upload a file end-to-end and return `{fileId, token}` ready to be used as
679
+ * a `_type:'FILE'` attach. Awaits the op 136 push so caller knows the file
680
+ * is fully ingested before sending the message.
681
+ */
682
+ async uploadFileBytes(file) {
683
+ const [info] = await this.requestFileUploadUrl(1);
684
+ if (!info)
685
+ throw new Error('No upload slot returned');
686
+ const { uploadFile } = await import('./media.js');
687
+ const done = new Promise((resolve) => {
688
+ const onDone = (p) => {
689
+ if (String(p.fileId) === String(info.fileId)) {
690
+ this.off('fileUploadDone', onDone);
691
+ resolve();
692
+ }
693
+ };
694
+ this.on('fileUploadDone', onDone);
695
+ });
696
+ await uploadFile(info.url, { data: file.data, filename: file.filename });
697
+ await done;
698
+ return info;
699
+ }
700
+ /** Send a photo message. `photoToken` from `uploadPhotoBytes()`. */
701
+ async sendPhoto(chatId, photoToken, caption = '') {
702
+ return this.sendMessage(chatId, caption, {
703
+ attaches: [{ _type: 'PHOTO', photoToken }],
704
+ });
705
+ }
706
+ /** Send a file message. `fileId` from `uploadFileBytes()`. */
707
+ async sendFile(chatId, fileId, caption = '') {
708
+ return this.sendMessage(chatId, caption, {
709
+ attaches: [{ _type: 'FILE', fileId }],
710
+ });
711
+ }
712
+ /** Send a sticker. `stickerId` from sticker pack listings (op 26). */
713
+ async sendSticker(chatId, stickerId) {
714
+ return this.sendMessage(chatId, '', {
715
+ attaches: [{ _type: 'STICKER', stickerId }],
716
+ });
717
+ }
718
+ /** Share a contact. */
719
+ async sendContact(chatId, contactId, caption = '') {
720
+ return this.sendMessage(chatId, caption, {
721
+ attaches: [{ _type: 'CONTACT', contactId }],
722
+ });
723
+ }
724
+ /** Add a sticker pack to favourites. */
725
+ async favoriteStickerPack(packId) {
726
+ const req = { type: 'FAVORITE_STICKER_SET', id: packId };
727
+ return this.transport.request(Opcode.STICKER_PACK_ACTION, req);
728
+ }
729
+ /** Remove a sticker pack from favourites. */
730
+ async unfavoriteStickerPack(packId) {
731
+ const req = { type: 'UNFAVORITE_STICKER_SET', id: packId };
732
+ return this.transport.request(Opcode.STICKER_PACK_ACTION, req);
733
+ }
734
+ // ====================================================================
735
+ // Polls (op 304, 306) + send-poll attach
736
+ // ====================================================================
737
+ /**
738
+ * Create a poll in a chat.
739
+ * - `settings` bitmask: 4 = anonymous, 12 = anon + visible voters list.
740
+ * - default = anonymous.
741
+ */
742
+ async sendPoll(chatId, title, answers, settings = 4) {
743
+ return this.sendMessage(chatId, '', {
744
+ attaches: [
745
+ {
746
+ _type: 'POLL',
747
+ title,
748
+ answers: answers.map((text) => ({ text })),
749
+ settings,
750
+ },
751
+ ],
752
+ });
753
+ }
754
+ /** Cast or change a vote. Pass multiple `answerIds` for multi-choice polls. */
755
+ async votePoll(chatId, messageId, pollId, answerIds) {
756
+ const req = { chatId, messageId, pollId, answersIds: answerIds };
757
+ const res = await this.transport.request(Opcode.POLL_VOTE, req);
758
+ return res.state;
759
+ }
760
+ /** Fetch current state of one or more polls. */
761
+ async getPolls(chatId, polls) {
762
+ const req = { chatId, polls };
763
+ const res = await this.transport.request(Opcode.POLL_GET, req);
764
+ return res.polls;
765
+ }
766
+ // ====================================================================
767
+ // Bot mini-apps + complaints (op 160, 162)
768
+ // ====================================================================
769
+ /** Open a bot mini-app and obtain its launch URL with signed WebAppData. */
770
+ async openBotMiniApp(botId, chatId) {
771
+ const req = { botId, chatId };
772
+ return this.transport.request(Opcode.BOT_MINI_APP_OPEN, req);
773
+ }
774
+ /** Fetch the localised report-reason catalogue (used to build report dialogs). */
775
+ async getComplainReasons(complainSync = 0) {
776
+ return this.transport.request(Opcode.COMPLAINS_SYNC, { complainSync });
777
+ }
778
+ // ====================================================================
779
+ // Folders (op 273, 274, 276)
780
+ // ====================================================================
781
+ /** Fetch specific folders by id. */
782
+ async getFolders(folderIds) {
783
+ const req = { folderIds };
784
+ const res = await this.transport.request(Opcode.FOLDERS_GET, req);
785
+ return res.folders;
786
+ }
787
+ /** Create or update a folder. Pass id from a previous folder for update. */
788
+ async upsertFolder(folder) {
789
+ return this.transport.request(Opcode.FOLDER_UPSERT, folder);
790
+ }
791
+ /**
792
+ * Reorder folders by sending the new id sequence.
793
+ *
794
+ * Op 275 (FOLDERS_REORDER) — earlier versions of this file used op 276,
795
+ * which is actually FOLDERS_DELETE. The fix follows the canonical opcode
796
+ * names extracted from the Android client.
797
+ */
798
+ async reorderFolders(folderIds) {
799
+ const req = { folderIds };
800
+ return this.transport.request(Opcode.FOLDERS_REORDER, req);
801
+ }
802
+ // ====================================================================
803
+ // Reactions config — batch get (op 258)
804
+ // ====================================================================
805
+ /** Read reactions policy for many chats at once. */
806
+ async getChatReactionsPolicies(chatIds) {
807
+ const req = { chatIds };
808
+ const res = await this.transport.request(Opcode.CHAT_REACTIONS_SETTINGS_GET, req);
809
+ return res.chatReactionsSettings;
810
+ }
811
+ // ====================================================================
812
+ // Channel-only sugar (op 55 with channel-specific fields)
813
+ // ====================================================================
814
+ /** Set / replace a chat (group or channel) avatar from an uploaded photo token. */
815
+ async setChatAvatar(chatId, photoToken) {
816
+ return this.updateChatSettings(chatId, { photoToken });
817
+ }
818
+ /** Rotate the private invite link of a group / channel. */
819
+ async revokeChatInviteLink(chatId) {
820
+ return this.updateChatSettings(chatId, { revokePrivateLink: true });
821
+ }
822
+ /** Toggle "join requires admin approval" on a channel. */
823
+ async setChannelJoinRequest(chatId, enabled) {
824
+ return this.updateChatSettings(chatId, { options: { JOIN_REQUEST: enabled } });
825
+ }
826
+ /** Unpin pinned message (sample observation: empty string clears it). */
827
+ async unpinMessage(chatId) {
828
+ return this.updateChatSettings(chatId, { pinMessageId: '' });
829
+ }
830
+ // ====================================================================
831
+ // Misc (op 5, 200)
832
+ // ====================================================================
833
+ /** Send analytics events to the server. Fire-and-forget. */
834
+ logEvents(events) {
835
+ const req = { events };
836
+ this.transport.request(Opcode.EVENT_LOG, req).catch(() => {
837
+ /* analytics: ignore */
838
+ });
839
+ }
840
+ /**
841
+ * Round-trip ping (op 1). Earlier versions used op 200 which is actually
842
+ * PROFILE_DELETE_TIME; the canonical keep-alive op in the Android client
843
+ * is the standard PING.
844
+ */
845
+ async keepalive() {
846
+ return this.transport.request(Opcode.PING, { interactive: true });
847
+ }
848
+ // ====================================================================
849
+ // Account / auth lifecycle (op 20, 158, 104–115)
850
+ // ====================================================================
851
+ /**
852
+ * Log out server-side (op 20) and wipe local session (deviceId is kept,
853
+ * loginToken cleared so next `start()` triggers a fresh QR login).
854
+ * Closes the websocket on the way out.
855
+ */
856
+ async logout() {
857
+ try {
858
+ await logoutOp(this.transport);
859
+ }
860
+ finally {
861
+ const sess = await this.session.load();
862
+ const next = { ...sess };
863
+ delete next.loginToken;
864
+ delete next.ownerId;
865
+ await this.session.save(next);
866
+ this.transport.close();
867
+ this.state = null;
868
+ this.me = null;
869
+ this.chats.clear();
870
+ }
871
+ }
872
+ /**
873
+ * Refresh the short-lived web token (op 158). MAX rotates this every
874
+ * ~14 minutes — call before the `token_refresh_ts` timestamp.
875
+ */
876
+ async refreshWebToken() {
877
+ return this.transport.request(Opcode.WEB_TOKEN, {});
878
+ }
879
+ /** Open a new auth track (op 112) used by all 2FA / email-recovery ops. */
880
+ async startAuthTrack(type = 0) {
881
+ const r = await startAuthTrack(this.transport, type);
882
+ return r.trackId;
883
+ }
884
+ /** Op 104: report whether a 2FA password is set, and the recovery email. */
885
+ async getPasswordInfo(trackId) {
886
+ const r = await getPasswordInfo(this.transport, trackId);
887
+ return r.password;
888
+ }
889
+ /**
890
+ * Enable 2FA (cloud password). Requires email ownership confirmation:
891
+ * 1. server emails a code to `recoveryEmail` (op 109)
892
+ * 2. caller resolves it via `getCode(blockingDuration, codeLength)`
893
+ * 3. code verified (op 110), password committed (op 111)
894
+ */
895
+ async enable2faPassword(opts) {
896
+ const { trackId } = await startAuthTrack(this.transport);
897
+ await validatePassword(this.transport, trackId, opts.password);
898
+ await setPasswordHint(this.transport, trackId, opts.hint);
899
+ const sent = await sendEmailCode(this.transport, trackId, opts.recoveryEmail);
900
+ const code = await opts.getCode({
901
+ blockingDuration: sent.blockingDuration,
902
+ codeLength: sent.codeLength,
903
+ });
904
+ await verifyEmailCode(this.transport, trackId, code);
905
+ await commitPasswordChange(this.transport, {
906
+ trackId,
907
+ expectedCapabilities: [0, 3, 4],
908
+ password: opts.password,
909
+ hint: opts.hint,
910
+ });
911
+ }
912
+ /** Disable 2FA cloud password (op 113 verify → op 111 with `remove2fa:true`). */
913
+ async disable2faPassword(currentPassword) {
914
+ const { trackId } = await startAuthTrack(this.transport);
915
+ await verifyPassword(this.transport, trackId, currentPassword);
916
+ await commitPasswordChange(this.transport, {
917
+ trackId,
918
+ expectedCapabilities: [5],
919
+ remove2fa: true,
920
+ });
921
+ }
922
+ /**
923
+ * Re-prove ownership of the 2FA password mid-session (op 113). Server may
924
+ * require this before sensitive settings changes.
925
+ */
926
+ async verify2faPassword(password) {
927
+ const { trackId } = await startAuthTrack(this.transport);
928
+ const r = await verifyPassword(this.transport, trackId, password);
929
+ return r.trackId;
930
+ }
931
+ /**
932
+ * Manually finish a 2FA-gated QR login (op 115). Use when you bypass the
933
+ * `resolvePassword` callback and prefer to drive the flow yourself.
934
+ */
935
+ async completeQrPasswordLogin(challenge, password) {
936
+ const r = await completeQrPasswordLogin(this.transport, challenge.trackId, password);
937
+ if (!r.tokenAttrs.LOGIN)
938
+ throw new Error('Server did not return LOGIN token after 2FA.');
939
+ return r.tokenAttrs.LOGIN.token;
940
+ }
941
+ // ====================================================================
942
+ // Chat ops added from capture (op 52, 53, 57, 70, 79, 82)
943
+ // ====================================================================
944
+ /**
945
+ * Clear chat history (op 52). Unlike `deleteChat` (op 54), the chat itself
946
+ * stays in the list; only messages up to `lastEventTimeMs` are wiped.
947
+ */
948
+ async clearChatHistory(chatId, forAll = false, lastEventTimeMs = Date.now()) {
949
+ const req = { chatId, forAll, lastEventTime: lastEventTimeMs };
950
+ await this.transport.request(Opcode.CHAT_CLEAR, req);
951
+ }
952
+ /**
953
+ * Incremental chat-list sync (op 53). Pass the largest `modified` timestamp
954
+ * you've seen; server returns chats updated after `marker`.
955
+ */
956
+ async syncChats(marker) {
957
+ const req = { marker };
958
+ const res = await this.transport.request(Opcode.CHATS_SYNC, req);
959
+ for (const c of res.chats)
960
+ this.chats.set(c.id, c);
961
+ return res.chats;
962
+ }
963
+ /**
964
+ * Resolve a public `https://max.ru/<slug>` or invite link to a `MaxChat`
965
+ * snapshot (op 57 — `CHAT_JOIN`). Canonical op 57 actually performs a join
966
+ * server-side; if you only want to peek without joining, use
967
+ * `raw(Opcode.CHAT_CHECK_LINK, { link })` (op 56) instead.
968
+ */
969
+ async resolveChatLink(link) {
970
+ const req = { link };
971
+ const res = await this.transport.request(Opcode.CHAT_RESOLVE_LINK, req);
972
+ return res.chat;
973
+ }
974
+ /**
975
+ * Server-side URL preview (op 70). Web client calls this when the user
976
+ * pastes a link into the composer; result is a `SHARE` attach you can
977
+ * pass straight to `sendMessage(..., { attaches })`.
978
+ */
979
+ async previewLink(text) {
980
+ const req = { text };
981
+ const res = await this.transport.request(Opcode.LINK_PREVIEW, req);
982
+ return res.attachments;
983
+ }
984
+ /** Recent calls history (op 79). Newest first when `forward:false`. */
985
+ async getCallHistory(opts = {}) {
986
+ const req = {
987
+ forward: opts.forward ?? false,
988
+ count: opts.count ?? 100,
989
+ };
990
+ if (opts.marker !== undefined)
991
+ req.marker = opts.marker;
992
+ const res = await this.transport.request(Opcode.CALL_HISTORY, req);
993
+ return res.history;
994
+ }
995
+ /**
996
+ * Allocate upload slot(s) (op 82). The same opcode serves video AND audio —
997
+ * pass `{uploaderType, type}` to select the CDN.
998
+ *
999
+ * From on-the-wire capture of `ru.oneme.app` v26.15.1:
1000
+ * - `{uploaderType:0, type:1}` → video (OK-CDN)
1001
+ * - `{uploaderType:1, type:2}` → audio / voice note (`au.oneme.ru/uploadAudio`)
1002
+ *
1003
+ * **Transport routing**: when `{uploaderType, type}` is provided we route
1004
+ * through a short-lived mobile binary connection — the WS transport
1005
+ * ignores these fields and always hands back a video URL. Without them,
1006
+ * we keep the historical WS path (cheap, no extra TLS handshake).
1007
+ */
1008
+ async requestVideoUploadUrl(count = 1, opts = {}) {
1009
+ const req = { count };
1010
+ if (opts.uploaderType !== undefined)
1011
+ req.uploaderType = opts.uploaderType;
1012
+ if (opts.type !== undefined)
1013
+ req.type = opts.type;
1014
+ if (opts.uploaderType !== undefined || opts.type !== undefined) {
1015
+ return this.runOnMobileTransport((raw) => raw.request(Opcode.VIDEO_UPLOAD_URL, req)).then((r) => r.info);
1016
+ }
1017
+ const res = await this.transport.request(Opcode.VIDEO_UPLOAD_URL, req);
1018
+ return res.info;
1019
+ }
1020
+ /**
1021
+ * Open a short-lived mobile binary session (HELLO + LOGIN with the saved
1022
+ * `mobileLoginToken`), run `fn`, and close. Used for opcodes whose audio
1023
+ * / video variant is mobile-only (e.g. op 82 with `{uploaderType:1, type:2}`).
1024
+ *
1025
+ * Throws if the session has no `mobileLoginToken` — i.e. the user never
1026
+ * logged in via phone-auth on this client.
1027
+ */
1028
+ async runOnMobileTransport(fn) {
1029
+ const sess = await this.session.load();
1030
+ if (!sess.mobileLoginToken) {
1031
+ throw new Error('Mobile transport unavailable: no mobileLoginToken in session. ' +
1032
+ 'Audio / video uploads require a phone-auth login (MaxClient.loginWithPhone).');
1033
+ }
1034
+ if (!sess.mobileDeviceId || !sess.mtInstanceId) {
1035
+ throw new Error('Mobile transport unavailable: missing mobileDeviceId / mtInstanceId in session.');
1036
+ }
1037
+ const raw = new RawTransport();
1038
+ try {
1039
+ await raw.connect();
1040
+ await raw.hello(sess.mobileDeviceId, sess.mtInstanceId);
1041
+ await raw.mobileLogin(sess.mobileLoginToken);
1042
+ return await fn(raw);
1043
+ }
1044
+ finally {
1045
+ raw.close();
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Send a regular video message (any aspect, any length within MAX limits).
1050
+ *
1051
+ * Like {@link sendVoice}, the whole upload + MSG_SEND flow runs in a
1052
+ * single short-lived mobile-binary session — WS-issued tokens are not
1053
+ * valid for the mobile-side VIDEO attach lookup.
1054
+ *
1055
+ * Wire format (captured from `ru.oneme.app` v26.15.1):
1056
+ * `{_type:'VIDEO', token, thumbhash?}`
1057
+ */
1058
+ async sendVideo(chatId, video, caption = '', opts = {}) {
1059
+ return this.sendVideoInternal(chatId, video, caption, opts, /* note */ false);
1060
+ }
1061
+ /**
1062
+ * Send a circular "video note" — short clip, square aspect, ≤ 60 s, no
1063
+ * caption. Same wire-format as {@link sendVideo} but with the
1064
+ * `videoType: 1` flag that makes the recipient client render the
1065
+ * round-clip UI.
1066
+ */
1067
+ async sendVideoNote(chatId, video, opts = {}) {
1068
+ return this.sendVideoInternal(chatId, video, '', opts, /* note */ true);
1069
+ }
1070
+ /**
1071
+ * Low-level: upload video bytes via op 82 + OK-CDN POST. Returns
1072
+ * `{token}`. The token is bound to the mobile session that issued it —
1073
+ * prefer {@link sendVideo} / {@link sendVideoNote} for the full E2E flow.
1074
+ */
1075
+ async uploadVideoBytes(file) {
1076
+ return this.runOnMobileTransport(async (raw) => {
1077
+ const slot = await raw.request(Opcode.VIDEO_UPLOAD_URL, {
1078
+ uploaderType: 0,
1079
+ type: 1,
1080
+ count: 1,
1081
+ });
1082
+ const info = slot.info?.[0];
1083
+ if (!info)
1084
+ throw new Error('No video upload slot returned');
1085
+ const { uploadOctetStream } = await import('./media.js');
1086
+ // Captured POST uses a negative-digit filename string (the mobile
1087
+ // client passes a Java `int` that overflowed once). We do the same
1088
+ // shape — random signed-int-looking digits — so the server's parser
1089
+ // is happy.
1090
+ const sniffName = `-${Math.floor(Math.random() * 2147483647)}`;
1091
+ await uploadOctetStream(info.url, file.data, sniffName, 'video');
1092
+ return { token: info.token };
1093
+ });
1094
+ }
1095
+ async sendVideoInternal(chatId, video, caption, opts, note) {
1096
+ const cid = opts.cid ?? -(Math.floor(Math.random() * 1e12));
1097
+ const thumbhash = opts.thumbhash ?? new Uint8Array(0);
1098
+ return this.runOnMobileTransport(async (raw) => {
1099
+ const slot = await raw.request(Opcode.VIDEO_UPLOAD_URL, {
1100
+ uploaderType: 0,
1101
+ type: 1,
1102
+ count: 1,
1103
+ });
1104
+ const info = slot.info?.[0];
1105
+ if (!info)
1106
+ throw new Error('No video upload slot returned');
1107
+ const { uploadOctetStream } = await import('./media.js');
1108
+ const sniffName = `-${Math.floor(Math.random() * 2147483647)}`;
1109
+ await uploadOctetStream(info.url, video.data, sniffName, 'video');
1110
+ const attach = {
1111
+ _type: 'VIDEO',
1112
+ token: info.token,
1113
+ thumbhash,
1114
+ };
1115
+ if (note)
1116
+ attach.videoType = 1;
1117
+ const send = await this.retryNotReady(() => raw.request(Opcode.SEND_MESSAGE, {
1118
+ chatId,
1119
+ message: {
1120
+ cid,
1121
+ text: caption,
1122
+ attaches: [attach],
1123
+ },
1124
+ notify: true,
1125
+ }));
1126
+ return (send.message ?? send);
1127
+ });
1128
+ }
1129
+ // ====================================================================
1130
+ // Voice / audio messages — full mobile-binary E2E (op 82 audio variant
1131
+ // upload + au.oneme.ru POST + op 64 MSG_SEND, all in one TLS session)
1132
+ // ====================================================================
1133
+ /**
1134
+ * Send a voice / audio message.
1135
+ *
1136
+ * The whole flow (request audio upload URL → POST raw bytes →
1137
+ * MSG_SEND with the AUDIO attach) runs on a short-lived mobile binary
1138
+ * connection. WS-side tokens issued by `op 82 {uploaderType:1, type:2}`
1139
+ * are **not** valid for WS `op 64` — the back-end keeps separate session
1140
+ * scopes, so we have to stay on mobile binary for the whole exchange.
1141
+ *
1142
+ * Wire format (captured from `ru.oneme.app` v26.15.1):
1143
+ * `{_type:'AUDIO', token, duration, wave?}`
1144
+ *
1145
+ * @param chatId target chat. `0` for Saved Messages.
1146
+ * @param audio `{data, filename?}` — bytes of the audio (any opus / ogg /
1147
+ * mp3 / m4a / aac). WAV PCM is rejected by the server
1148
+ * (`video.not.supported`).
1149
+ * @param opts `durationMs` (**milliseconds** — the wire field is ms!)
1150
+ * OR `duration` (seconds — auto-multiplied by 1000); `wave`
1151
+ * — raw 80-byte waveform buffer (omit for flat waveform);
1152
+ * `cid` — client-side message id (auto-generated otherwise).
1153
+ */
1154
+ async sendVoice(chatId, audio, opts = {}) {
1155
+ const durationMs = opts.durationMs !== undefined
1156
+ ? opts.durationMs
1157
+ : opts.duration !== undefined
1158
+ ? Math.round(opts.duration * 1000)
1159
+ : 0;
1160
+ const cid = opts.cid ?? -(Math.floor(Math.random() * 1e12));
1161
+ // Compute a real 80-byte waveform via ffmpeg if the caller didn't
1162
+ // provide one — gives the recipient a proper bar UI instead of a flat
1163
+ // line. Falls back to zeros if ffmpeg isn't on PATH.
1164
+ let wave = opts.wave;
1165
+ if (!wave) {
1166
+ const { computeWaveform } = await import('./media.js');
1167
+ wave = (await computeWaveform(audio.data)) ?? new Uint8Array(80);
1168
+ }
1169
+ return this.runOnMobileTransport(async (raw) => {
1170
+ // 1. Allocate audio upload slot.
1171
+ const slot = await raw.request(Opcode.VIDEO_UPLOAD_URL, {
1172
+ uploaderType: 1,
1173
+ type: 2,
1174
+ count: 1,
1175
+ });
1176
+ const info = slot.info?.[0];
1177
+ if (!info)
1178
+ throw new Error('No audio upload slot returned');
1179
+ // 2. POST bytes to the signed au.oneme.ru/uploadAudio URL.
1180
+ const { uploadOctetStream } = await import('./media.js');
1181
+ const sniffName = String(Math.floor(Math.random() * 1e10));
1182
+ await uploadOctetStream(info.url, audio.data, sniffName);
1183
+ // 3. MSG_SEND with the AUDIO attach (still on the mobile session).
1184
+ //
1185
+ // Server-side audio ingest is async — straight after the HTTP POST,
1186
+ // `op 64` often returns `errors.process.attachment.video.not.ready`.
1187
+ // Retry with exponential backoff (≤ 8 s total). The mobile client
1188
+ // observably does the same in this race window.
1189
+ const send = await this.retryNotReady(() => raw.request(Opcode.SEND_MESSAGE, {
1190
+ chatId,
1191
+ message: {
1192
+ cid,
1193
+ attaches: [
1194
+ {
1195
+ _type: 'AUDIO',
1196
+ token: info.token,
1197
+ duration: durationMs,
1198
+ wave,
1199
+ },
1200
+ ],
1201
+ },
1202
+ notify: true,
1203
+ }));
1204
+ return (send.message ?? send);
1205
+ });
1206
+ }
1207
+ /**
1208
+ * Retry helper for the `errors.process.attachment.video.not.ready` race
1209
+ * that occurs when MSG_SEND lands before the server finishes ingesting
1210
+ * the just-uploaded media. Attempts at 500ms, 1s, 2s, 4s (≈ 8s total).
1211
+ */
1212
+ async retryNotReady(fn) {
1213
+ const delays = [500, 1000, 2000, 4000];
1214
+ let lastErr;
1215
+ for (let i = 0; i <= delays.length; i++) {
1216
+ try {
1217
+ return await fn();
1218
+ }
1219
+ catch (e) {
1220
+ lastErr = e;
1221
+ const msg = e instanceof Error ? e.message : String(e);
1222
+ if (!/not\.ready/i.test(msg))
1223
+ throw e;
1224
+ if (i === delays.length)
1225
+ throw e;
1226
+ await new Promise((r) => setTimeout(r, delays[i]));
1227
+ }
1228
+ }
1229
+ throw lastErr;
1230
+ }
1231
+ /**
1232
+ * Upload an audio file via op 82 (audio variant) and return `{token}`.
1233
+ * **NOTE:** the returned `token` is bound to a mobile session — it is NOT
1234
+ * accepted by WS `op 64`. Use {@link sendVoice} for the full E2E send;
1235
+ * this method is kept for parity / low-level callers.
1236
+ */
1237
+ async uploadAudioBytes(file) {
1238
+ return this.runOnMobileTransport(async (raw) => {
1239
+ const slot = await raw.request(Opcode.VIDEO_UPLOAD_URL, {
1240
+ uploaderType: 1,
1241
+ type: 2,
1242
+ count: 1,
1243
+ });
1244
+ const info = slot.info?.[0];
1245
+ if (!info)
1246
+ throw new Error('No audio upload slot returned');
1247
+ const { uploadOctetStream } = await import('./media.js');
1248
+ const sniffName = String(Math.floor(Math.random() * 1e10));
1249
+ await uploadOctetStream(info.url, file.data, sniffName);
1250
+ return { token: info.token };
1251
+ });
1252
+ }
1253
+ // ====================================================================
1254
+ // Location messages (op 64 with LOCATION attach)
1255
+ // ====================================================================
1256
+ /**
1257
+ * Send a geolocation message. Coordinates are validated server-side —
1258
+ * pass valid WGS-84 lat/lng. Optional `name`/`address` show as a labelled
1259
+ * preview alongside the map.
1260
+ */
1261
+ async sendLocation(chatId, latitude, longitude, opts = {}) {
1262
+ const attach = {
1263
+ _type: 'LOCATION',
1264
+ latitude,
1265
+ longitude,
1266
+ };
1267
+ if (opts.name !== undefined)
1268
+ attach.name = opts.name;
1269
+ if (opts.address !== undefined)
1270
+ attach.address = opts.address;
1271
+ return this.sendMessage(chatId, '', { attaches: [attach] });
1272
+ }
1273
+ // ====================================================================
1274
+ // Miscellaneous helpers (transcribe, contact-search, bot, message-by-id)
1275
+ // ====================================================================
1276
+ /**
1277
+ * Fetch a single message by id from a chat (op 71 — MSG_GET).
1278
+ * Returns `undefined` if the message is not found or already deleted.
1279
+ */
1280
+ async getMessage(chatId, messageId) {
1281
+ const res = await this.transport.request(Opcode.MSG_GET, { chatId, messageIds: [messageId] });
1282
+ return res.messages?.[0];
1283
+ }
1284
+ /**
1285
+ * Search your contacts by free-text (name / phone fragment) — op 37.
1286
+ * Server returns the best matches sorted by relevance.
1287
+ *
1288
+ * The server may key the array under different names depending on the
1289
+ * route (`contacts` for the contact-book search, `users` for the global
1290
+ * directory fallback). We coalesce both into a single array so callers
1291
+ * don't have to branch.
1292
+ */
1293
+ async searchContacts(query, count = 30) {
1294
+ const res = await this.transport.request(Opcode.CONTACT_SEARCH, { query, count });
1295
+ const arr = res.contacts ??
1296
+ res.users ??
1297
+ res.result ??
1298
+ [];
1299
+ return arr;
1300
+ }
1301
+ /**
1302
+ * Get the list of contacts you share with another user (op 38).
1303
+ * Different from {@link getCommonChats} — this is users you both know,
1304
+ * not chats you're both in.
1305
+ */
1306
+ async getMutualContacts(userId) {
1307
+ const res = await this.transport.request(Opcode.CONTACT_MUTUAL, { userId });
1308
+ return res.contacts;
1309
+ }
1310
+ /**
1311
+ * Request audio-to-text transcription of a voice / audio message (op 202).
1312
+ * The actual transcription is computed asynchronously; the response is the
1313
+ * task acknowledgement. The finished transcript arrives via push op 293
1314
+ * (`NOTIF_TRANSCRIPTION`) which is forwarded as the `'raw'` event for now.
1315
+ */
1316
+ async transcribeMedia(chatId, messageId) {
1317
+ await this.transport.request(Opcode.TRANSCRIBE_MEDIA, { chatId, messageId });
1318
+ }
1319
+ /**
1320
+ * Fetch a bot's metadata + description (op 145).
1321
+ * `botId` is the user id of the bot account.
1322
+ */
1323
+ async getBotInfo(botId) {
1324
+ return this.transport.request(Opcode.BOT_INFO, { botId });
1325
+ }
1326
+ /**
1327
+ * List a bot's `/`-commands as configured by its owner via BotFather-like
1328
+ * flow (op 144).
1329
+ */
1330
+ async getBotCommands(botId, chatId) {
1331
+ const req = { botId };
1332
+ if (chatId !== undefined)
1333
+ req.chatId = chatId;
1334
+ return this.transport.request(Opcode.CHAT_BOT_COMMANDS, req);
1335
+ }
1336
+ /**
1337
+ * Save a local draft to the server-side so it syncs across your devices
1338
+ * (op 176). `text` may be empty to clear; pass `cid` if echoing a message
1339
+ * that's being drafted.
1340
+ */
1341
+ async saveDraft(chatId, text, opts = {}) {
1342
+ const req = { chatId, draft: { text } };
1343
+ if (opts.cid !== undefined)
1344
+ req.draft.cid = opts.cid;
1345
+ await this.transport.request(Opcode.DRAFT_SAVE, req);
1346
+ }
1347
+ /**
1348
+ * Discard the server-side draft for a chat (op 177).
1349
+ */
1350
+ async discardDraft(chatId) {
1351
+ await this.transport.request(Opcode.DRAFT_DISCARD, { chatId });
1352
+ }
1353
+ /**
1354
+ * Close a specific active session by id (op 97).
1355
+ * Use {@link listSessions} to discover ids. The current session can't be
1356
+ * closed via this op — use {@link logout} instead.
1357
+ */
1358
+ async closeSession(sessionId) {
1359
+ await this.transport.request(Opcode.SESSIONS_CLOSE, { sessionId });
1360
+ }
1361
+ // ====================================================================
1362
+ // High-level entry points (static factories)
1363
+ // ====================================================================
1364
+ /**
1365
+ * One-shot phone-number login with automatic web QR-bind.
1366
+ *
1367
+ * On first call:
1368
+ * 1. opens a mobile binary transport (TLS to api.oneme.ru:443)
1369
+ * 2. requests an SMS code (op 17) → calls `getSmsCode()`
1370
+ * 3. submits the code (op 18) → mobile LOGIN token (or 2FA challenge)
1371
+ * 4. if 2FA: calls `getPassword()` → submits via op 115
1372
+ * 5. completes the mobile session (op 19)
1373
+ * 6. opens a web WS and does QR-bind via op 290 ↔ ops 288/289/291
1374
+ * (mobile transport plays the role of the "scanning device" — no
1375
+ * camera, no other phone needed)
1376
+ * 7. saves both tokens to the session store and returns a ready
1377
+ * {@link MaxClient} connected via WS.
1378
+ *
1379
+ * On subsequent calls: finds the saved web token and silently reconnects;
1380
+ * `getSmsCode` / `getPassword` are never invoked.
1381
+ *
1382
+ * ```ts
1383
+ * const client = await MaxClient.loginWithPhone({
1384
+ * phone: '+79991234567',
1385
+ * getSmsCode: async () => readline('SMS code: '),
1386
+ * getPassword: async (challenge) => readline(`2FA (hint: ${challenge.hint}): `),
1387
+ * sessionFile: './.max-session.json',
1388
+ * });
1389
+ * client.on('message', m => console.log(m.fromId, m.text));
1390
+ * await client.sendMessage(0, 'hi from phone-login');
1391
+ * ```
1392
+ */
1393
+ static async loginWithPhone(opts) {
1394
+ const session = MaxClient.resolveSession(opts);
1395
+ const sess = await session.load();
1396
+ // Fresh login: run phone-auth + qr-bind, persist both tokens.
1397
+ if (!sess.loginToken) {
1398
+ if (!sess.mobileDeviceId || !sess.mtInstanceId) {
1399
+ throw new Error('Session missing mobileDeviceId/mtInstanceId — delete the session file and retry.');
1400
+ }
1401
+ const raw = new RawTransport(opts.debug ? { debug: true } : {});
1402
+ raw.on('error', (e) => {
1403
+ // Surface low-level transport errors to stderr — pending requests
1404
+ // also reject, but a TLS-level reset can fire before any request is
1405
+ // outstanding (e.g. server closes the socket after rate-limiting).
1406
+ // eslint-disable-next-line no-console
1407
+ console.error('[raw err]', e.message);
1408
+ });
1409
+ await raw.connect();
1410
+ // Cache the 2FA password so we only ask the user once even though
1411
+ // both mobile (op 115) and web (op 115) may need it.
1412
+ let cachedPassword = null;
1413
+ const askPassword = async (challenge) => {
1414
+ if (cachedPassword !== null)
1415
+ return cachedPassword;
1416
+ if (!opts.getPassword)
1417
+ return null;
1418
+ const pw = await opts.getPassword(challenge);
1419
+ if (pw !== null && pw !== undefined && pw !== '') {
1420
+ cachedPassword = pw;
1421
+ }
1422
+ return pw ?? null;
1423
+ };
1424
+ try {
1425
+ await raw.hello(sess.mobileDeviceId, sess.mtInstanceId);
1426
+ let mobileToken = sess.mobileLoginToken;
1427
+ if (!mobileToken) {
1428
+ // ── Phone auth ──────────────────────────────────────────────
1429
+ const phone = typeof opts.phone === 'function' ? await opts.phone() : opts.phone;
1430
+ if (!phone)
1431
+ throw new Error('Phone number not provided — phone login aborted');
1432
+ const start = await sendPhoneAuthCode(raw, phone);
1433
+ const code = await opts.getSmsCode();
1434
+ if (!code)
1435
+ throw new Error('SMS code not provided — phone login aborted');
1436
+ const verify = await verifyPhoneAuthCode(raw, code, start.token);
1437
+ mobileToken = verify.tokenAttrs?.LOGIN?.token;
1438
+ if (!mobileToken && verify.passwordChallenge) {
1439
+ const pw = await askPassword(verify.passwordChallenge);
1440
+ if (!pw) {
1441
+ throw new Error('Account has a 2FA cloud password — provide `getPassword` callback');
1442
+ }
1443
+ const final = await raw.request(Opcode.AUTH_QR_PASSWORD_LOGIN, { trackId: verify.passwordChallenge.trackId, password: pw }, { prependOpHint: 0x33, flags: 1 });
1444
+ mobileToken = final.tokenAttrs?.LOGIN?.token;
1445
+ }
1446
+ if (!mobileToken) {
1447
+ throw new Error('Mobile LOGIN token not issued — phone auth failed');
1448
+ }
1449
+ // ── Persist mobile token immediately ────────────────────────
1450
+ // If qr-bind fails below, the phone-auth work isn't wasted — next
1451
+ // run skips straight to qr-bind via the saved `mobileLoginToken`.
1452
+ await session.save({ ...sess, mobileLoginToken: mobileToken });
1453
+ }
1454
+ else if (opts.debug) {
1455
+ // eslint-disable-next-line no-console
1456
+ console.error('[loginWithPhone] resuming with saved mobileLoginToken, skipping phone-auth');
1457
+ }
1458
+ // ── Activate mobile session (op 19) so op 290 is allowed ────
1459
+ // eslint-disable-next-line no-console
1460
+ if (opts.debug)
1461
+ console.error('[loginWithPhone] mobileLogin (op 19)…');
1462
+ await raw.mobileLogin(mobileToken);
1463
+ // ── Settle the mobile socket: a PING + small delay lets server
1464
+ // finish any deferred push events before we open the parallel
1465
+ // web WS, which otherwise can trigger a server-side reset.
1466
+ // eslint-disable-next-line no-console
1467
+ if (opts.debug)
1468
+ console.error('[loginWithPhone] settle PING (op 1)…');
1469
+ await raw.request(Opcode.PING, { interactive: true }).catch(() => { });
1470
+ await new Promise((r) => setTimeout(r, 800));
1471
+ // ── Web QR-bind (mobile op 290 ↔ web ops 288/289/291) ───────
1472
+ // eslint-disable-next-line no-console
1473
+ if (opts.debug)
1474
+ console.error('[loginWithPhone] qr-bind…');
1475
+ let bind;
1476
+ try {
1477
+ bind = await qrBindWebSession(raw, {
1478
+ webDeviceId: sess.deviceId,
1479
+ ...(opts.userAgent ? { webUserAgent: opts.userAgent } : {}),
1480
+ ...(opts.chatsCount !== undefined ? { chatsCount: opts.chatsCount } : {}),
1481
+ resolvePassword: askPassword,
1482
+ });
1483
+ }
1484
+ catch (e) {
1485
+ // eslint-disable-next-line no-console
1486
+ console.error('[loginWithPhone] qr-bind FAILED:', e.message);
1487
+ // eslint-disable-next-line no-console
1488
+ console.error('[loginWithPhone] Mobile token is saved — retry `MaxClient.loginWithPhone(...)` ' +
1489
+ 'to resume qr-bind without re-entering SMS/2FA.');
1490
+ throw e;
1491
+ }
1492
+ // ── Persist tokens ──────────────────────────────────────────
1493
+ const next = {
1494
+ ...sess,
1495
+ deviceId: bind.webDeviceId,
1496
+ loginToken: bind.webToken,
1497
+ mobileLoginToken: mobileToken,
1498
+ ownerId: bind.loginResponse.profile.contact.id,
1499
+ };
1500
+ await session.save(next);
1501
+ }
1502
+ finally {
1503
+ raw.close();
1504
+ }
1505
+ }
1506
+ // ── Open WS-based MaxClient with the (now persisted) web token ──
1507
+ const client = new MaxClient({
1508
+ ...opts,
1509
+ session,
1510
+ // We already have a token — never print QR / never invoke QR flow.
1511
+ printQr: false,
1512
+ printCredentialsAfterLogin: false,
1513
+ });
1514
+ await client.start();
1515
+ return client;
1516
+ }
1517
+ // --- internal ---
1518
+ handlePush(frame) {
1519
+ if (frame.opcode === Opcode.CHAT_UPDATE_PUSH) {
1520
+ const p = frame.payload;
1521
+ if (p?.chat) {
1522
+ this.chats.set(p.chat.id, p.chat);
1523
+ this.emit('chatUpdate', p.chat);
1524
+ }
1525
+ return;
1526
+ }
1527
+ if (frame.opcode === Opcode.READ_MARK_PUSH) {
1528
+ const p = frame.payload;
1529
+ if (p)
1530
+ this.emit('readByOther', p);
1531
+ return;
1532
+ }
1533
+ if (frame.opcode === Opcode.CONFIG_HASH_PUSH) {
1534
+ const p = frame.payload;
1535
+ if (p?.config?.hash)
1536
+ this.emit('configChanged', p.config.hash);
1537
+ return;
1538
+ }
1539
+ if (frame.opcode === Opcode.FILE_UPLOAD_DONE_PUSH) {
1540
+ const p = frame.payload;
1541
+ if (p)
1542
+ this.emit('fileUploadDone', p);
1543
+ return;
1544
+ }
1545
+ if (frame.opcode === Opcode.STICKER_RECENTS_PUSH) {
1546
+ const p = frame.payload;
1547
+ if (p)
1548
+ this.emit('stickerRecentsUpdate', p);
1549
+ return;
1550
+ }
1551
+ if (frame.opcode === Opcode.MEMBER_LEAVE_PUSH) {
1552
+ const p = frame.payload;
1553
+ if (p)
1554
+ this.emit('memberLeave', p);
1555
+ return;
1556
+ }
1557
+ if (frame.opcode === Opcode.PRESENCE_PUSH) {
1558
+ const p = frame.payload;
1559
+ if (p)
1560
+ this.emit('presence', p);
1561
+ return;
1562
+ }
1563
+ if (frame.opcode === Opcode.PROFILE_UPDATE_PUSH) {
1564
+ const p = frame.payload;
1565
+ const c = p?.profile?.contact;
1566
+ if (c) {
1567
+ if (this.me && c.id === this.me.id)
1568
+ this.me = c;
1569
+ this.emit('contactUpdate', c);
1570
+ }
1571
+ return;
1572
+ }
1573
+ if (frame.opcode === Opcode.MESSAGE_INCOMING) {
1574
+ const p = frame.payload;
1575
+ if (!p?.message)
1576
+ return;
1577
+ // Ack the push so the server stops retrying.
1578
+ this.transport.ackPush(frame, { chatId: p.chatId, messageId: p.message.id });
1579
+ const evt = {
1580
+ chatId: p.chatId,
1581
+ messageId: p.message.id,
1582
+ text: p.message.text ?? '',
1583
+ fromId: p.message.sender,
1584
+ time: p.message.time,
1585
+ raw: p.message,
1586
+ };
1587
+ this.emit('message', evt);
1588
+ if (this.autoRead) {
1589
+ this.readMessage(p.chatId, p.message.id, p.message.time).catch((err) => this.emit('error', err));
1590
+ }
1591
+ }
1592
+ }
1593
+ async rehandshake() {
1594
+ const sess = await this.session.load();
1595
+ await hello(this.transport, sess.deviceId, this.userAgent);
1596
+ if (sess.loginToken) {
1597
+ await loginWithToken(this.transport, sess.loginToken, { chatsCount: this.chatsCount });
1598
+ }
1599
+ }
1600
+ }
1601
+ //# sourceMappingURL=client.js.map