@leemour/max-cli 0.9.0 → 0.11.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/README.md +24 -3
- package/dist/backup.d.ts +28 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +47 -0
- package/dist/backup.js.map +1 -0
- package/dist/cache/drivers/bun-sqlite.d.ts.map +1 -1
- package/dist/cache/drivers/node-sqlite.d.ts.map +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +10 -4
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/open.d.ts.map +1 -1
- package/dist/cache/schema.d.ts.map +1 -1
- package/dist/cache/store.d.ts +7 -0
- package/dist/cache/store.d.ts.map +1 -1
- package/dist/cache/store.js +8 -0
- package/dist/cache/store.js.map +1 -1
- package/dist/client.d.ts +73 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +354 -66
- package/dist/client.js.map +1 -1
- package/dist/commands/backup.d.ts +3 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +101 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/body.d.ts.map +1 -1
- package/dist/commands/body.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +6 -5
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/chats.js +2 -2
- package/dist/commands/chats.js.map +1 -1
- package/dist/commands/complete.d.ts.map +1 -1
- package/dist/commands/complete.js +28 -22
- package/dist/commands/complete.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +5 -1
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/contacts.d.ts.map +1 -1
- package/dist/commands/context.d.ts +4 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +7 -15
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +136 -15
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/folders.js.map +1 -1
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/messages.d.ts.map +1 -1
- package/dist/commands/messages.js +6 -4
- package/dist/commands/messages.js.map +1 -1
- package/dist/commands/models.d.ts.map +1 -1
- package/dist/commands/models.js +29 -23
- package/dist/commands/models.js.map +1 -1
- package/dist/commands/paging.d.ts.map +1 -1
- package/dist/commands/recipients.d.ts.map +1 -1
- package/dist/commands/recipients.js +25 -17
- package/dist/commands/recipients.js.map +1 -1
- package/dist/commands/sends.d.ts.map +1 -1
- package/dist/commands/sends.js +8 -6
- package/dist/commands/sends.js.map +1 -1
- package/dist/commands/serve.d.ts +12 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +10 -7
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/server.d.ts +7 -0
- package/dist/commands/server.d.ts.map +1 -0
- package/dist/commands/server.js +84 -0
- package/dist/commands/server.js.map +1 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +10 -10
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +73 -38
- package/dist/commands/watch.js.map +1 -1
- package/dist/config.d.ts +5 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +19 -2
- package/dist/config.js.map +1 -1
- package/dist/deadline.d.ts.map +1 -1
- package/dist/diagnose.d.ts +17 -3
- package/dist/diagnose.d.ts.map +1 -1
- package/dist/diagnose.js +16 -4
- package/dist/diagnose.js.map +1 -1
- package/dist/domain/map.d.ts.map +1 -1
- package/dist/domain/map.js.map +1 -1
- package/dist/domain/models.d.ts +20 -0
- package/dist/domain/models.d.ts.map +1 -1
- package/dist/domain/models.js.map +1 -1
- package/dist/download.d.ts +22 -3
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +133 -27
- package/dist/download.js.map +1 -1
- package/dist/export.d.ts +10 -4
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +26 -18
- package/dist/export.js.map +1 -1
- package/dist/generated/client.generated.d.ts +10 -0
- package/dist/generated/client.generated.d.ts.map +1 -1
- package/dist/generated/client.generated.js +10 -0
- package/dist/generated/client.generated.js.map +1 -1
- package/dist/generated/opcodes.generated.d.ts +4 -0
- package/dist/generated/opcodes.generated.d.ts.map +1 -1
- package/dist/generated/opcodes.generated.js +4 -0
- package/dist/generated/opcodes.generated.js.map +1 -1
- package/dist/generated/operations.generated.d.ts +35 -1
- package/dist/generated/operations.generated.d.ts.map +1 -1
- package/dist/generated/operations.generated.js +8 -1
- package/dist/generated/operations.generated.js.map +1 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/mcp/confirm.d.ts +19 -11
- package/dist/mcp/confirm.d.ts.map +1 -1
- package/dist/mcp/confirm.js +53 -29
- package/dist/mcp/confirm.js.map +1 -1
- package/dist/mcp/instructions.d.ts.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +9 -4
- package/dist/mcp/tools.js.map +1 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +24 -2
- package/dist/output.js.map +1 -1
- package/dist/profile.d.ts.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +55 -5
- package/dist/program.js.map +1 -1
- package/dist/protocol/connection.d.ts +2 -0
- package/dist/protocol/connection.d.ts.map +1 -1
- package/dist/protocol/connection.js +27 -5
- package/dist/protocol/connection.js.map +1 -1
- package/dist/protocol/frame.d.ts +2 -0
- package/dist/protocol/frame.d.ts.map +1 -1
- package/dist/protocol/frame.js +3 -1
- package/dist/protocol/frame.js.map +1 -1
- package/dist/protocol/lz4.d.ts.map +1 -1
- package/dist/protocol/lz4.js +8 -5
- package/dist/protocol/lz4.js.map +1 -1
- package/dist/rendering/messages.d.ts.map +1 -1
- package/dist/rendering/messages.js +9 -10
- package/dist/rendering/messages.js.map +1 -1
- package/dist/report.d.ts +43 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +91 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +4 -3
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +9 -10
- package/dist/resolve.js.map +1 -1
- package/dist/runs/events.d.ts +21 -1
- package/dist/runs/events.d.ts.map +1 -1
- package/dist/runs/events.js +14 -1
- package/dist/runs/events.js.map +1 -1
- package/dist/runs/recording.d.ts +12 -0
- package/dist/runs/recording.d.ts.map +1 -1
- package/dist/runs/recording.js +83 -22
- package/dist/runs/recording.js.map +1 -1
- package/dist/runs/run.d.ts +11 -0
- package/dist/runs/run.d.ts.map +1 -1
- package/dist/runs/run.js +8 -1
- package/dist/runs/run.js.map +1 -1
- package/dist/sends/guard.d.ts +35 -4
- package/dist/sends/guard.d.ts.map +1 -1
- package/dist/sends/guard.js +128 -28
- package/dist/sends/guard.js.map +1 -1
- package/dist/sends/journal.d.ts +17 -3
- package/dist/sends/journal.d.ts.map +1 -1
- package/dist/sends/journal.js +62 -2
- package/dist/sends/journal.js.map +1 -1
- package/dist/sends/permissions.d.ts.map +1 -1
- package/dist/sends/permissions.js.map +1 -1
- package/dist/sends/recipients.d.ts +3 -1
- package/dist/sends/recipients.d.ts.map +1 -1
- package/dist/sends/recipients.js +7 -2
- package/dist/sends/recipients.js.map +1 -1
- package/dist/server/lines.d.ts +8 -2
- package/dist/server/lines.d.ts.map +1 -1
- package/dist/server/lines.js +13 -2
- package/dist/server/lines.js.map +1 -1
- package/dist/server/server-connection.d.ts +9 -6
- package/dist/server/server-connection.d.ts.map +1 -1
- package/dist/server/server-connection.js +16 -7
- package/dist/server/server-connection.js.map +1 -1
- package/dist/server/server.d.ts +21 -4
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +154 -17
- package/dist/server/server.js.map +1 -1
- package/dist/server/start.d.ts +3 -1
- package/dist/server/start.d.ts.map +1 -1
- package/dist/server/start.js +6 -1
- package/dist/server/start.js.map +1 -1
- package/dist/server/subscribe.d.ts.map +1 -1
- package/dist/session/adopt.d.ts.map +1 -1
- package/dist/session/browser.d.ts.map +1 -1
- package/dist/session/browser.js.map +1 -1
- package/dist/session/handshake.d.ts +17 -3
- package/dist/session/handshake.d.ts.map +1 -1
- package/dist/session/handshake.js +11 -5
- package/dist/session/handshake.js.map +1 -1
- package/dist/session/login.d.ts.map +1 -1
- package/dist/session/prompt.d.ts.map +1 -1
- package/dist/session/prompt.js +7 -1
- package/dist/session/prompt.js.map +1 -1
- package/dist/session/qr-terminal.d.ts.map +1 -1
- package/dist/session/store.d.ts +18 -1
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +32 -1
- package/dist/session/store.js.map +1 -1
- package/dist/spec/check.d.ts.map +1 -1
- package/dist/spec/define.d.ts +12 -0
- package/dist/spec/define.d.ts.map +1 -1
- package/dist/spec/define.js.map +1 -1
- package/dist/spec/guards.d.ts +16 -0
- package/dist/spec/guards.d.ts.map +1 -0
- package/dist/spec/guards.js +30 -0
- package/dist/spec/guards.js.map +1 -0
- package/dist/spec/identity.d.ts +39 -6
- package/dist/spec/identity.d.ts.map +1 -1
- package/dist/spec/identity.js +41 -12
- package/dist/spec/identity.js.map +1 -1
- package/dist/spec/index.d.ts.map +1 -1
- package/dist/spec/index.js +8 -1
- package/dist/spec/index.js.map +1 -1
- package/dist/spec/operations/account.d.ts.map +1 -1
- package/dist/spec/operations/account.js +3 -0
- package/dist/spec/operations/account.js.map +1 -1
- package/dist/spec/operations/assets.d.ts +10 -0
- package/dist/spec/operations/assets.d.ts.map +1 -0
- package/dist/spec/operations/assets.js +20 -0
- package/dist/spec/operations/assets.js.map +1 -0
- package/dist/spec/operations/attachments.d.ts.map +1 -1
- package/dist/spec/operations/attachments.js +2 -0
- package/dist/spec/operations/attachments.js.map +1 -1
- package/dist/spec/operations/banners.d.ts +7 -0
- package/dist/spec/operations/banners.d.ts.map +1 -0
- package/dist/spec/operations/banners.js +16 -0
- package/dist/spec/operations/banners.js.map +1 -0
- package/dist/spec/operations/calls.d.ts +8 -0
- package/dist/spec/operations/calls.d.ts.map +1 -0
- package/dist/spec/operations/calls.js +18 -0
- package/dist/spec/operations/calls.js.map +1 -0
- package/dist/spec/operations/chats.d.ts +5 -3
- package/dist/spec/operations/chats.d.ts.map +1 -1
- package/dist/spec/operations/chats.js +58 -9
- package/dist/spec/operations/chats.js.map +1 -1
- package/dist/spec/operations/contacts.d.ts.map +1 -1
- package/dist/spec/operations/contacts.js +8 -0
- package/dist/spec/operations/contacts.js.map +1 -1
- package/dist/spec/operations/folders.d.ts.map +1 -1
- package/dist/spec/operations/folders.js +5 -0
- package/dist/spec/operations/folders.js.map +1 -1
- package/dist/spec/operations/login.d.ts.map +1 -1
- package/dist/spec/operations/login.js +6 -0
- package/dist/spec/operations/login.js.map +1 -1
- package/dist/spec/operations/messages.d.ts.map +1 -1
- package/dist/spec/operations/messages.js +50 -0
- package/dist/spec/operations/messages.js.map +1 -1
- package/dist/spec/operations/session.d.ts +28 -1
- package/dist/spec/operations/session.d.ts.map +1 -1
- package/dist/spec/operations/session.js +47 -1
- package/dist/spec/operations/session.js.map +1 -1
- package/dist/spec/operations/uploads.d.ts.map +1 -1
- package/dist/spec/operations/uploads.js +2 -0
- package/dist/spec/operations/uploads.js.map +1 -1
- package/dist/spec/scalars.d.ts.map +1 -1
- package/dist/transcribe/index.d.ts.map +1 -1
- package/dist/transcribe/index.js +4 -1
- package/dist/transcribe/index.js.map +1 -1
- package/dist/transcribe/install.d.ts +3 -1
- package/dist/transcribe/install.d.ts.map +1 -1
- package/dist/transcribe/install.js +24 -1
- package/dist/transcribe/install.js.map +1 -1
- package/dist/transcribe/models.d.ts.map +1 -1
- package/dist/transcribe/speech.d.ts.map +1 -1
- package/dist/transcribe/speech.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js.map +1 -1
- package/dist/upload.d.ts +3 -1
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +34 -4
- package/dist/upload.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +18 -15
- package/skills/max-cli/SKILL.md +12 -1
- package/dist/testing/mock-max.d.ts +0 -49
- package/dist/testing/mock-max.d.ts.map +0 -1
- package/dist/testing/mock-max.js +0 -80
- package/dist/testing/mock-max.js.map +0 -1
- package/dist/testing/sandbox.d.ts +0 -2
- package/dist/testing/sandbox.d.ts.map +0 -1
- package/dist/testing/sandbox.js +0 -36
- package/dist/testing/sandbox.js.map +0 -1
- package/dist/testing/unscripted.d.ts +0 -2
- package/dist/testing/unscripted.d.ts.map +0 -1
- package/dist/testing/unscripted.js +0 -18
- package/dist/testing/unscripted.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { CliError } from "@leemour/cli-core";
|
|
2
2
|
import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
|
|
3
|
+
import { heldWindows } from "./export.js";
|
|
3
4
|
import { wireClient } from "./generated/client.generated.js";
|
|
4
5
|
import { parseMarkdown } from "./markdown.js";
|
|
5
6
|
import { asFirstWord } from "./profile.js";
|
|
6
7
|
import { Connection, ProtocolError } from "./protocol/connection.js";
|
|
7
8
|
import { asId } from "./protocol/frame.js";
|
|
8
9
|
import { isId, pickChat, pickPerson } from "./resolve.js";
|
|
9
|
-
import { countsIn, idsOf } from "./runs/events.js";
|
|
10
|
+
import { countsIn, idsOf, maxErrorKey } from "./runs/events.js";
|
|
10
11
|
import { LOGIN_CHATS, startSession } from "./session/handshake.js";
|
|
11
12
|
import { tokenByQr } from "./session/login.js";
|
|
13
|
+
import { loginPausedUntil, withLoginRefused, withoutLoginPause, } from "./session/store.js";
|
|
12
14
|
import { WEB_USER_AGENT } from "./spec/identity.js";
|
|
13
15
|
import { buildRequest, checkResponse } from "./spec/index.js";
|
|
16
|
+
import { ASSET_TYPES } from "./spec/operations/assets.js";
|
|
14
17
|
import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
|
|
15
18
|
/**
|
|
16
19
|
* **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
|
|
@@ -28,6 +31,7 @@ export class MaxClient {
|
|
|
28
31
|
#store;
|
|
29
32
|
#connection;
|
|
30
33
|
#fullLogin;
|
|
34
|
+
#resume;
|
|
31
35
|
#warn;
|
|
32
36
|
#cache;
|
|
33
37
|
#offline;
|
|
@@ -36,12 +40,14 @@ export class MaxClient {
|
|
|
36
40
|
#invoke = ((operation, request) => this.#send(operation, request));
|
|
37
41
|
#wire = wireClient(this.#invoke);
|
|
38
42
|
#login;
|
|
43
|
+
#chatsCut = false;
|
|
39
44
|
#previousCid = 0;
|
|
40
45
|
#people;
|
|
41
46
|
#merged;
|
|
42
|
-
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, }) {
|
|
47
|
+
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, resume, }) {
|
|
43
48
|
this.#store = store;
|
|
44
49
|
this.#fullLogin = fullLogin;
|
|
50
|
+
this.#resume = resume;
|
|
45
51
|
this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
|
|
46
52
|
this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
47
53
|
this.#cache = cache;
|
|
@@ -90,7 +96,11 @@ export class MaxClient {
|
|
|
90
96
|
// The other devices are out from here on, whatever follows fails: the journal says so.
|
|
91
97
|
done();
|
|
92
98
|
const token = answer.token;
|
|
93
|
-
if (typeof token === "string" && token !== "") {
|
|
99
|
+
if (typeof token === "string" && token !== "" && this.#fromEnvironment()) {
|
|
100
|
+
this.#warnAbout("token_not_saved", "MAX gave this session a new token, and it was not saved: the one in use came from MAX_TOKEN — " +
|
|
101
|
+
"if MAX_TOKEN stops working, log in again and set it anew");
|
|
102
|
+
}
|
|
103
|
+
else if (typeof token === "string" && token !== "") {
|
|
94
104
|
try {
|
|
95
105
|
this.#store.writeToken(token);
|
|
96
106
|
}
|
|
@@ -186,6 +196,12 @@ export class MaxClient {
|
|
|
186
196
|
const cache = this.#cache;
|
|
187
197
|
return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
|
|
188
198
|
},
|
|
199
|
+
/** The other person in a one-to-one chat, by the chat's participants; `undefined` for anything else. */
|
|
200
|
+
partner: async (chatId) => {
|
|
201
|
+
await this.#connectOnce();
|
|
202
|
+
const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
|
|
203
|
+
return raw ? this.#partnerOf(raw) : undefined;
|
|
204
|
+
},
|
|
189
205
|
/** What a link leads to, without joining it. */
|
|
190
206
|
inspect: async (link) => {
|
|
191
207
|
if (this.#offline)
|
|
@@ -209,13 +225,18 @@ export class MaxClient {
|
|
|
209
225
|
markRead: async (chatId, messageId) => {
|
|
210
226
|
if (this.#offline)
|
|
211
227
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot mark a chat read");
|
|
212
|
-
this.#guard(chatId, "read", messageId);
|
|
228
|
+
this.#guard({ chatId, kind: "read" }, messageId);
|
|
213
229
|
try {
|
|
214
230
|
await this.#connectOnce();
|
|
215
231
|
const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
|
|
216
232
|
if (!upTo)
|
|
217
233
|
throw new CliError("not_found", `chat ${chatId} has no messages to mark read`);
|
|
218
|
-
|
|
234
|
+
// The web client's mark is the read message's own time, not the moment of reading: a later
|
|
235
|
+
// one would mark newer messages read too (captured 2026-09-25, `RES-10`).
|
|
236
|
+
const mark = timeOfMessageId(upTo);
|
|
237
|
+
if (mark === undefined)
|
|
238
|
+
throw new CliError("validation_error", `"${upTo}" is not a message id`);
|
|
239
|
+
const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark });
|
|
219
240
|
this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
|
|
220
241
|
return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
|
|
221
242
|
}
|
|
@@ -235,20 +256,23 @@ export class MaxClient {
|
|
|
235
256
|
* Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
|
|
236
257
|
* whether MAX deduplicates a creation by `cid` is not measured.
|
|
237
258
|
*/
|
|
238
|
-
create: (title, people = []) =>
|
|
259
|
+
create: async (title, people = []) => {
|
|
239
260
|
const userIds = await this.#personIds(people);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
261
|
+
return this.#changeChat(null, "create", async () => {
|
|
262
|
+
const answer = await this.#wire.messages.send({
|
|
263
|
+
message: {
|
|
264
|
+
cid: this.#nextCid(),
|
|
265
|
+
attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
|
|
266
|
+
},
|
|
267
|
+
notify: true,
|
|
268
|
+
});
|
|
269
|
+
const chat = toGroupCard(record(answer.chat) ?? {});
|
|
270
|
+
return { chatId: chat.id, result: chat, people: userIds.length };
|
|
271
|
+
}, userIds);
|
|
272
|
+
},
|
|
250
273
|
members: {
|
|
251
|
-
|
|
274
|
+
/** No history unless asked (`NEED-272`): what was said before somebody joined is not theirs by default. */
|
|
275
|
+
add: (reference, people, { history = false } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
|
|
252
276
|
remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
|
|
253
277
|
},
|
|
254
278
|
admins: {
|
|
@@ -483,7 +507,6 @@ export class MaxClient {
|
|
|
483
507
|
itemType: "DELAYED",
|
|
484
508
|
getChat: false,
|
|
485
509
|
getMessages: true,
|
|
486
|
-
interactive: false,
|
|
487
510
|
});
|
|
488
511
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
489
512
|
return asArray(answer.messages)
|
|
@@ -524,6 +547,52 @@ export class MaxClient {
|
|
|
524
547
|
const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 });
|
|
525
548
|
return { items: messages, hasMore: messages.length >= limit };
|
|
526
549
|
},
|
|
550
|
+
/**
|
|
551
|
+
* **Fills a chat's history backwards into the cache, page by page**, the way web.max.ru pages
|
|
552
|
+
* it when scrolled up (`RES-9`, captured 2026-09-25): 30 back from the time of the oldest
|
|
553
|
+
* message loaded, so each page repeats one message, and a shorter page is the chat's start.
|
|
554
|
+
*
|
|
555
|
+
* Stretches the cache already read completely are stepped over, so a second run continues
|
|
556
|
+
* where the first stopped. It ends at `since`, at `last` messages held, at the chat's start, or
|
|
557
|
+
* after `maxPages` — and on the first error, with no retry: what the limit error of MAX looks
|
|
558
|
+
* like is unknown, so every error is treated as one. Reactions are not read.
|
|
559
|
+
*/
|
|
560
|
+
backup: async (chatId, { since, last, maxPages, pause, onPage, }) => {
|
|
561
|
+
const cache = this.#cache;
|
|
562
|
+
if (!cache)
|
|
563
|
+
throw new CliError("not_found", "the local copy could not be opened, and a backup is kept there");
|
|
564
|
+
let cursor = Date.now();
|
|
565
|
+
let pages = 0;
|
|
566
|
+
for (;;) {
|
|
567
|
+
const held = heldWindows(cache.messages.ranges(chatId)).find((window) => window.from <= cursor && cursor <= window.to);
|
|
568
|
+
if (held)
|
|
569
|
+
cursor = held.from;
|
|
570
|
+
if (held?.from === 0)
|
|
571
|
+
return { pages, complete: true, reachedStart: true };
|
|
572
|
+
if (since !== undefined && cursor <= since)
|
|
573
|
+
return { pages, complete: true, reachedStart: false };
|
|
574
|
+
if (last !== undefined && cache.messages.count(chatId, cursor) >= last) {
|
|
575
|
+
return { pages, complete: true, reachedStart: false };
|
|
576
|
+
}
|
|
577
|
+
if (pages >= maxPages)
|
|
578
|
+
return { pages, complete: false, reachedStart: false };
|
|
579
|
+
if (pages > 0)
|
|
580
|
+
await pause();
|
|
581
|
+
const page = await this.#history(chatId, { from: cursor, backward: BACKUP_PAGE, forward: 0 }, { reactions: false });
|
|
582
|
+
pages += 1;
|
|
583
|
+
const oldest = page[0] ? Date.parse(page[0].timestamp) : cursor;
|
|
584
|
+
onPage?.({ number: pages, count: page.length, oldest: page[0]?.timestamp ?? null });
|
|
585
|
+
if (page.length < BACKUP_PAGE) {
|
|
586
|
+
if (page.length > 0 || pages > 1)
|
|
587
|
+
cache.messages.reachedStart(chatId, oldest);
|
|
588
|
+
return { pages, complete: true, reachedStart: true };
|
|
589
|
+
}
|
|
590
|
+
if (oldest >= cursor) {
|
|
591
|
+
throw new CliError("invalid_response", `MAX answered page ${pages} with nothing older than it was asked for`);
|
|
592
|
+
}
|
|
593
|
+
cursor = oldest;
|
|
594
|
+
}
|
|
595
|
+
},
|
|
527
596
|
/**
|
|
528
597
|
* **One message and a window either side of it**, oldest first, the one asked for marked
|
|
529
598
|
* `anchor: true`.
|
|
@@ -631,20 +700,30 @@ export class MaxClient {
|
|
|
631
700
|
if (options.at !== undefined && options.cid !== undefined) {
|
|
632
701
|
throw new CliError("validation_error", `--cid repeats an ambiguous send, which is not safe for a scheduled one — \`max messages scheduled ${chatId}\` shows whether it is queued`);
|
|
633
702
|
}
|
|
703
|
+
// Read before the guard holds a place under the limit: a file that is refused sends nothing.
|
|
704
|
+
const files = await Promise.all((options.files ?? []).map(async (path) => ({
|
|
705
|
+
path,
|
|
706
|
+
bytes: await readUpload(path, { anyFile: options.anyFile === true }),
|
|
707
|
+
photo: isImage(path),
|
|
708
|
+
})));
|
|
709
|
+
// Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
|
|
710
|
+
if (files.some((file) => !file.photo) && files.length > 1) {
|
|
711
|
+
throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
|
|
712
|
+
}
|
|
634
713
|
// Before connecting: a refused send never opens a socket when the chat was given as an id.
|
|
635
714
|
try {
|
|
636
|
-
this.#sends?.check(
|
|
715
|
+
this.#sends?.check({
|
|
716
|
+
chatId,
|
|
717
|
+
kind: "message",
|
|
718
|
+
...(options.cid === undefined ? {} : { cid: options.cid }),
|
|
719
|
+
...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
|
|
720
|
+
});
|
|
637
721
|
}
|
|
638
722
|
catch (error) {
|
|
639
723
|
this.#sends?.record({ chatId, outcome: "refused", errorCode: asCliError(error).code });
|
|
640
724
|
throw error;
|
|
641
725
|
}
|
|
642
726
|
const cid = options.cid ?? this.#nextCid();
|
|
643
|
-
const files = await Promise.all((options.files ?? []).map(async (path) => ({ path, bytes: await readUpload(path), photo: isImage(path) })));
|
|
644
|
-
// Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
|
|
645
|
-
if (files.some((file) => !file.photo) && files.length > 1) {
|
|
646
|
-
throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
|
|
647
|
-
}
|
|
648
727
|
const attachments = files.map(({ bytes, photo }) => ({
|
|
649
728
|
kind: photo ? "photo" : "file",
|
|
650
729
|
bytes: bytes.length,
|
|
@@ -687,7 +766,7 @@ export class MaxClient {
|
|
|
687
766
|
edit: async (chatId, messageId, text, { markdown = false } = {}) => {
|
|
688
767
|
if (this.#offline)
|
|
689
768
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
|
|
690
|
-
this.#guard(chatId, "edit", messageId);
|
|
769
|
+
this.#guard({ chatId, kind: "edit" }, messageId);
|
|
691
770
|
try {
|
|
692
771
|
await this.#connectOnce();
|
|
693
772
|
const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
|
|
@@ -729,7 +808,7 @@ export class MaxClient {
|
|
|
729
808
|
forward: async (fromChatId, messageId, toChatId, options = {}) => {
|
|
730
809
|
if (this.#offline)
|
|
731
810
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
|
|
732
|
-
this.#guard(toChatId, "forward");
|
|
811
|
+
this.#guard({ chatId: toChatId, kind: "forward" });
|
|
733
812
|
const cid = options.cid ?? this.#nextCid();
|
|
734
813
|
try {
|
|
735
814
|
const sent = await this.#deliver(toChatId, "", cid, {
|
|
@@ -767,7 +846,7 @@ export class MaxClient {
|
|
|
767
846
|
throw new CliError("validation_error", `${messageIds.length} messages at once — at most ${DELETE_AT_ONCE} per call, so MAX sees deletions spread out`);
|
|
768
847
|
}
|
|
769
848
|
const count = messageIds.length;
|
|
770
|
-
this.#guard(chatId, "delete",
|
|
849
|
+
this.#guard({ chatId, kind: "delete", count });
|
|
771
850
|
try {
|
|
772
851
|
await this.#connectOnce();
|
|
773
852
|
await this.#wire.messages.delete({ chatId, messageIds, forMe: !forEveryone });
|
|
@@ -798,7 +877,7 @@ export class MaxClient {
|
|
|
798
877
|
pin: async (chatId, messageId, { notify = false } = {}) => {
|
|
799
878
|
if (this.#offline)
|
|
800
879
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
|
|
801
|
-
this.#guard(chatId, "pin", messageId ?? undefined);
|
|
880
|
+
this.#guard({ chatId, kind: "pin", notify }, messageId ?? undefined);
|
|
802
881
|
try {
|
|
803
882
|
await this.#connectOnce();
|
|
804
883
|
const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
|
|
@@ -806,11 +885,11 @@ export class MaxClient {
|
|
|
806
885
|
throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
|
|
807
886
|
}
|
|
808
887
|
await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
|
|
809
|
-
this.#sends?.record({ chatId, kind: "pin", outcome: "sent", ...(messageId ? { messageId } : {}) });
|
|
888
|
+
this.#sends?.record({ chatId, kind: "pin", notify, outcome: "sent", ...(messageId ? { messageId } : {}) });
|
|
810
889
|
return { chatId, pinned: messageId };
|
|
811
890
|
}
|
|
812
891
|
catch (error) {
|
|
813
|
-
this.#sends?.record({ chatId, kind: "pin", outcome: "failed", errorCode: asCliError(error).code });
|
|
892
|
+
this.#sends?.record({ chatId, kind: "pin", notify, outcome: "failed", errorCode: asCliError(error).code });
|
|
814
893
|
throw error;
|
|
815
894
|
}
|
|
816
895
|
},
|
|
@@ -876,7 +955,7 @@ export class MaxClient {
|
|
|
876
955
|
if (theirs.length > 0)
|
|
877
956
|
found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
|
|
878
957
|
}
|
|
879
|
-
return { mode: "unread", chats: found, skipped, partial: !this.#cache &&
|
|
958
|
+
return { mode: "unread", chats: found, skipped, partial: !this.#cache && this.#chatsCut };
|
|
880
959
|
},
|
|
881
960
|
/**
|
|
882
961
|
* Other people's messages in every chat that changed after `since`.
|
|
@@ -916,7 +995,7 @@ export class MaxClient {
|
|
|
916
995
|
until: new Date(until).toISOString(),
|
|
917
996
|
chats: found,
|
|
918
997
|
skipped,
|
|
919
|
-
partial: !this.#cache &&
|
|
998
|
+
partial: !this.#cache && this.#chatsCut && changed.length === chats.length,
|
|
920
999
|
};
|
|
921
1000
|
},
|
|
922
1001
|
};
|
|
@@ -929,7 +1008,7 @@ export class MaxClient {
|
|
|
929
1008
|
if (this.#offline)
|
|
930
1009
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
|
|
931
1010
|
try {
|
|
932
|
-
this.#sends?.check(chatId, "reaction");
|
|
1011
|
+
this.#sends?.check({ chatId, kind: "reaction" });
|
|
933
1012
|
}
|
|
934
1013
|
catch (error) {
|
|
935
1014
|
this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
|
|
@@ -947,14 +1026,16 @@ export class MaxClient {
|
|
|
947
1026
|
}
|
|
948
1027
|
}
|
|
949
1028
|
/** Asks the send guard, and writes a refusal to the send journal before passing it on. */
|
|
950
|
-
#guard(
|
|
1029
|
+
#guard(request, messageId) {
|
|
1030
|
+
const { chatId, kind, notify } = request;
|
|
951
1031
|
try {
|
|
952
|
-
this.#sends?.check(
|
|
1032
|
+
this.#sends?.check(request);
|
|
953
1033
|
}
|
|
954
1034
|
catch (error) {
|
|
955
1035
|
this.#sends?.record({
|
|
956
1036
|
chatId,
|
|
957
|
-
kind,
|
|
1037
|
+
...(kind ? { kind } : {}),
|
|
1038
|
+
...(notify === undefined ? {} : { notify }),
|
|
958
1039
|
outcome: "refused",
|
|
959
1040
|
...(messageId ? { messageId } : {}),
|
|
960
1041
|
errorCode: asCliError(error).code,
|
|
@@ -973,7 +1054,6 @@ export class MaxClient {
|
|
|
973
1054
|
forward: 0,
|
|
974
1055
|
backward: 1,
|
|
975
1056
|
getMessages: true,
|
|
976
|
-
interactive: false,
|
|
977
1057
|
});
|
|
978
1058
|
const found = asArray(answer.messages)
|
|
979
1059
|
.map((raw) => record(raw) ?? {})
|
|
@@ -1035,11 +1115,65 @@ export class MaxClient {
|
|
|
1035
1115
|
await this.#connectOnce();
|
|
1036
1116
|
await this.#wire.session.ping({ interactive: false });
|
|
1037
1117
|
},
|
|
1118
|
+
/**
|
|
1119
|
+
* What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
|
|
1120
|
+
* `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
|
|
1121
|
+
*/
|
|
1122
|
+
/**
|
|
1123
|
+
* **The reads a web tab sends after every login** (`MAX-52`, recorded 2026-09-25), in its order
|
|
1124
|
+
* and all at once, as it sends them: folders, banners, call history, then the four asset sets.
|
|
1125
|
+
* Each carries the sync value its previous answer returned — 0 the first time — and the answers
|
|
1126
|
+
* are kept only for those. `max serve` alone sends them; a one-shot command never did.
|
|
1127
|
+
*/
|
|
1128
|
+
readLikeTab: async (sync) => {
|
|
1129
|
+
await this.#connectOnce();
|
|
1130
|
+
const folders = this.#wire.folders.list({ folderSync: sync.folders });
|
|
1131
|
+
const banners = this.#wire.banners.list({ bannersSync: 0 });
|
|
1132
|
+
const calls = this.#wire.calls.history({ callHistorySync: sync.calls });
|
|
1133
|
+
const assets = ASSET_TYPES.map((type) => this.#wire.assets.update({ type, sync: sync.assets[type] ?? 0 }));
|
|
1134
|
+
const [folderAnswer, , callAnswer, answers] = await Promise.all([folders, banners, calls, Promise.all(assets)]);
|
|
1135
|
+
return {
|
|
1136
|
+
folders: numberOr(folderAnswer.folderSync, sync.folders),
|
|
1137
|
+
calls: numberOr(callAnswer.callHistorySync, sync.calls),
|
|
1138
|
+
assets: Object.fromEntries(ASSET_TYPES.map((type, at) => [type, numberOr(answers[at]?.sync, sync.assets[type] ?? 0)])),
|
|
1139
|
+
};
|
|
1140
|
+
},
|
|
1141
|
+
chatListShown: async ({ at, sessionId }) => {
|
|
1142
|
+
const viewerId = this.#store.readState().viewerId;
|
|
1143
|
+
if (!viewerId)
|
|
1144
|
+
return;
|
|
1145
|
+
await this.#connectOnce();
|
|
1146
|
+
await this.#wire.session.log({
|
|
1147
|
+
events: [
|
|
1148
|
+
{
|
|
1149
|
+
type: "NAV",
|
|
1150
|
+
userId: viewerId,
|
|
1151
|
+
time: at,
|
|
1152
|
+
sessionId,
|
|
1153
|
+
event: "GO",
|
|
1154
|
+
params: { action_id: 1, screen_to: 150, prev_time: 0, source_id: viewerId },
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
});
|
|
1158
|
+
},
|
|
1038
1159
|
/**
|
|
1039
1160
|
* The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
|
|
1040
1161
|
* a command as that command's own login.
|
|
1041
1162
|
*/
|
|
1042
1163
|
snapshot: () => this.#session(),
|
|
1164
|
+
/**
|
|
1165
|
+
* What the next login on a new connection sends to pick up where this one is (`MAX-51`):
|
|
1166
|
+
* `undefined` when this login lacks what that needs, and the next one is then a full login.
|
|
1167
|
+
*/
|
|
1168
|
+
resumeFrom: () => {
|
|
1169
|
+
const login = this.#login;
|
|
1170
|
+
const configHash = record(login?.config)?.hash;
|
|
1171
|
+
if (!login || typeof login.time !== "number" || typeof configHash !== "string")
|
|
1172
|
+
return undefined;
|
|
1173
|
+
const chats = asArray(login.chats).map((chat) => record(chat) ?? {});
|
|
1174
|
+
const chatsSync = Math.max(0, ...chats.map(eventTime));
|
|
1175
|
+
return { login: { lastLogin: login.time, chatsSync, configHash }, chats };
|
|
1176
|
+
},
|
|
1043
1177
|
/**
|
|
1044
1178
|
* Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
|
|
1045
1179
|
* the server stops handing it out until it has logged in again. What web.max.ru does with each
|
|
@@ -1082,6 +1216,9 @@ export class MaxClient {
|
|
|
1082
1216
|
const at = chats.findIndex((candidate) => asId(candidate.id) === id);
|
|
1083
1217
|
if (at >= 0)
|
|
1084
1218
|
chats[at] = changed;
|
|
1219
|
+
// Past this, a new login rebuilds the list rather than pushes growing it without end.
|
|
1220
|
+
else if (chats.length >= LIVE_CHATS)
|
|
1221
|
+
return false;
|
|
1085
1222
|
else
|
|
1086
1223
|
chats.push(changed);
|
|
1087
1224
|
this.#session().chats = chats;
|
|
@@ -1101,17 +1238,47 @@ export class MaxClient {
|
|
|
1101
1238
|
*/
|
|
1102
1239
|
message: async (opcode, payload) => {
|
|
1103
1240
|
const raw = record(payload.message);
|
|
1241
|
+
if (opcode !== NEW_MESSAGE || !raw || raw.status !== undefined)
|
|
1242
|
+
return undefined;
|
|
1243
|
+
return this.#messageHit(payload);
|
|
1244
|
+
},
|
|
1245
|
+
/** An edit, a deletion or a reaction pushed by MAX, or `undefined` for anything else. */
|
|
1246
|
+
change: async (opcode, payload) => {
|
|
1104
1247
|
const chatId = asId(payload.chatId);
|
|
1105
|
-
if (
|
|
1248
|
+
if (chatId === undefined)
|
|
1106
1249
|
return undefined;
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1250
|
+
const raw = record(payload.message);
|
|
1251
|
+
if (opcode === NEW_MESSAGE && raw?.status === "EDITED") {
|
|
1252
|
+
const message = await this.#messageHit(payload);
|
|
1253
|
+
return message && { event: "edit", message };
|
|
1254
|
+
}
|
|
1255
|
+
const messageId = asId(opcode === NEW_MESSAGE ? raw?.id : payload.messageId);
|
|
1256
|
+
if (messageId === undefined)
|
|
1257
|
+
return undefined;
|
|
1258
|
+
if (opcode === NEW_MESSAGE && raw?.status === "REMOVED") {
|
|
1259
|
+
return { event: "delete", chatId, chatTitle: await this.#chatTitle(chatId), messageId };
|
|
1260
|
+
}
|
|
1261
|
+
if (opcode === REACTIONS_CHANGED) {
|
|
1262
|
+
const reactions = toReactions(payload);
|
|
1263
|
+
return { event: "reaction", chatId, chatTitle: await this.#chatTitle(chatId), messageId, reactions };
|
|
1264
|
+
}
|
|
1265
|
+
return undefined;
|
|
1113
1266
|
},
|
|
1114
1267
|
};
|
|
1268
|
+
async #messageHit(payload) {
|
|
1269
|
+
const raw = record(payload.message);
|
|
1270
|
+
const chatId = asId(payload.chatId);
|
|
1271
|
+
if (!raw || chatId === undefined)
|
|
1272
|
+
return undefined;
|
|
1273
|
+
const session = this.#session();
|
|
1274
|
+
const [message] = await this.#nameSenders([
|
|
1275
|
+
toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
|
|
1276
|
+
]);
|
|
1277
|
+
return message && { ...message, chatTitle: await this.#chatTitle(chatId) };
|
|
1278
|
+
}
|
|
1279
|
+
async #chatTitle(chatId) {
|
|
1280
|
+
return (await this.chats.list()).items.find((candidate) => candidate.id === chatId)?.title ?? null;
|
|
1281
|
+
}
|
|
1115
1282
|
/**
|
|
1116
1283
|
* Opens the connection and logs in with the stored token, or with one offered for trial.
|
|
1117
1284
|
*
|
|
@@ -1124,11 +1291,21 @@ export class MaxClient {
|
|
|
1124
1291
|
async connect({ token: candidate } = {}) {
|
|
1125
1292
|
const token = candidate ?? this.#store.readToken();
|
|
1126
1293
|
if (!token) {
|
|
1294
|
+
const profile = this.#store.profile;
|
|
1295
|
+
// cli-core swallows a keyring that will not open, so the state file is the only witness: a
|
|
1296
|
+
// profile that has logged in lost its keyring, not its session, and another login would
|
|
1297
|
+
// register one more device for nothing (MAX-50, measured from cron 2026-09-25).
|
|
1298
|
+
if (this.#store.hasLoggedIn()) {
|
|
1299
|
+
throw new CliError("authentication_error", `no token found for profile "${profile}", although it has logged in on this machine — ` +
|
|
1300
|
+
`the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
|
|
1301
|
+
`\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
|
|
1302
|
+
}
|
|
1127
1303
|
// The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
|
|
1128
1304
|
// in under is the ordinary shape of this failure now that the first word is the profile.
|
|
1129
|
-
throw new CliError("authentication_error", `no session for profile "${
|
|
1305
|
+
throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
|
|
1130
1306
|
}
|
|
1131
1307
|
const state = this.#store.readState();
|
|
1308
|
+
refuseWhilePaused(state);
|
|
1132
1309
|
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
1133
1310
|
try {
|
|
1134
1311
|
await this.#connection.open();
|
|
@@ -1136,10 +1313,17 @@ export class MaxClient {
|
|
|
1136
1313
|
token,
|
|
1137
1314
|
deviceId: state.deviceId,
|
|
1138
1315
|
...(sync === undefined ? {} : { sync }),
|
|
1316
|
+
...(this.#resume ? { resume: this.#resume.login } : {}),
|
|
1139
1317
|
});
|
|
1140
1318
|
}
|
|
1141
1319
|
catch (error) {
|
|
1142
|
-
|
|
1320
|
+
const failure = asCliError(error);
|
|
1321
|
+
if (failure.code !== "rate_limited")
|
|
1322
|
+
throw failure;
|
|
1323
|
+
const paused = withLoginRefused(state);
|
|
1324
|
+
this.#store.writeState(paused);
|
|
1325
|
+
throw new CliError("rate_limited", `${failure.message} — this profile will not log in again before ${paused.loginPausedUntil}; ` +
|
|
1326
|
+
"logging in sooner is what keeps an account locked");
|
|
1143
1327
|
}
|
|
1144
1328
|
const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
|
|
1145
1329
|
// **Before `writeState`**, so a login we are about to refuse does not count itself or move
|
|
@@ -1151,14 +1335,59 @@ export class MaxClient {
|
|
|
1151
1335
|
`run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
|
|
1152
1336
|
}
|
|
1153
1337
|
this.#store.writeState({
|
|
1154
|
-
...state,
|
|
1338
|
+
...withoutLoginPause(state),
|
|
1155
1339
|
...(viewerId ? { viewerId } : {}),
|
|
1156
1340
|
logins: state.logins + 1,
|
|
1157
1341
|
lastLoginAt: new Date().toISOString(),
|
|
1158
1342
|
});
|
|
1159
1343
|
this.#keepRotatedToken(token);
|
|
1344
|
+
// `max serve` hands this login to any process that asks its socket; none of them needs the token.
|
|
1345
|
+
const { token: _, ...withoutToken } = this.#login;
|
|
1346
|
+
this.#login = withoutToken;
|
|
1347
|
+
if (this.#resume)
|
|
1348
|
+
this.#mergeChanged(this.#resume.chats);
|
|
1349
|
+
else
|
|
1350
|
+
await this.#readRestOfChats();
|
|
1160
1351
|
this.#mergeLogin(viewerId);
|
|
1161
1352
|
}
|
|
1353
|
+
/**
|
|
1354
|
+
* A login with `chatsSync` answers only the chats that changed since it (measured 2026-09-20), so
|
|
1355
|
+
* they go over the list the previous connection held: same id replaced, the rest kept, newest first.
|
|
1356
|
+
*/
|
|
1357
|
+
#mergeChanged(before) {
|
|
1358
|
+
const session = this.#session();
|
|
1359
|
+
const changed = asArray(session.chats);
|
|
1360
|
+
const ids = new Set(changed.map((chat) => asId(record(chat)?.id)));
|
|
1361
|
+
const kept = before.filter((chat) => !ids.has(asId(chat.id)));
|
|
1362
|
+
session.chats = [...changed, ...kept].sort((a, b) => eventTime(b) - eventTime(a));
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
|
|
1366
|
+
* time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
|
|
1367
|
+
* same day: that answers exactly the chats after it, and one answer held 26. Only one request,
|
|
1368
|
+
* because the tab sends only one — `#chatsCut` says when that may not have been all.
|
|
1369
|
+
*
|
|
1370
|
+
* A refusal leaves the login's chats as they were: a shorter list beats a failed command.
|
|
1371
|
+
*/
|
|
1372
|
+
async #readRestOfChats() {
|
|
1373
|
+
this.#chatsCut = false;
|
|
1374
|
+
const session = this.#session();
|
|
1375
|
+
const chats = asArray(session.chats);
|
|
1376
|
+
const marker = record(chats.at(-1))?.lastEventTime;
|
|
1377
|
+
if (chats.length < LOGIN_CHATS || (typeof marker !== "number" && typeof marker !== "bigint"))
|
|
1378
|
+
return;
|
|
1379
|
+
try {
|
|
1380
|
+
const answer = await this.#wire.chats.list({ marker: Number(marker) });
|
|
1381
|
+
const known = new Set(chats.map((chat) => asId(record(chat)?.id)));
|
|
1382
|
+
const rest = asArray(answer.chats).filter((chat) => !known.has(asId(record(chat)?.id)));
|
|
1383
|
+
session.chats = [...chats, ...rest];
|
|
1384
|
+
this.#chatsCut = rest.length >= CHATS_PAGE_SEEN;
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
this.#chatsCut = true;
|
|
1388
|
+
this.#warnAbout("chats_partial", `only the newest ${chats.length} chats were read: ${reasonOf(error)}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1162
1391
|
/**
|
|
1163
1392
|
* **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
|
|
1164
1393
|
* away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
|
|
@@ -1184,11 +1413,15 @@ export class MaxClient {
|
|
|
1184
1413
|
const rotated = this.#session().token;
|
|
1185
1414
|
if (typeof rotated !== "string" || rotated === "" || rotated === sent)
|
|
1186
1415
|
return;
|
|
1416
|
+
if (this.#fromEnvironment()) {
|
|
1417
|
+
this.#warnAbout("token_not_saved", "MAX refreshed the session; the fresh token was not saved, because the one in use came from MAX_TOKEN");
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1187
1420
|
try {
|
|
1188
1421
|
this.#store.writeToken(rotated);
|
|
1189
1422
|
}
|
|
1190
1423
|
catch (error) {
|
|
1191
|
-
this.#
|
|
1424
|
+
this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
|
|
1192
1425
|
}
|
|
1193
1426
|
}
|
|
1194
1427
|
/**
|
|
@@ -1234,7 +1467,7 @@ export class MaxClient {
|
|
|
1234
1467
|
});
|
|
1235
1468
|
}
|
|
1236
1469
|
catch (error) {
|
|
1237
|
-
this.#
|
|
1470
|
+
this.#warnAbout("cache_not_written", `the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
|
|
1238
1471
|
}
|
|
1239
1472
|
}
|
|
1240
1473
|
/** One page of contacts out of the store — the same query online and offline. */
|
|
@@ -1270,7 +1503,10 @@ export class MaxClient {
|
|
|
1270
1503
|
* no login, and is over before a socket would have finished its handshake. `connect()` stays
|
|
1271
1504
|
* public for the one command that must reach MAX to mean anything — starting a session.
|
|
1272
1505
|
*/
|
|
1273
|
-
/**
|
|
1506
|
+
/**
|
|
1507
|
+
* Never `CHAT_MARK`: reading history must not mark anything read (§19). Without `interactive`, as
|
|
1508
|
+
* the web client asks — measured 2026-09-25 on a channel with 8 unread: reading moved nothing.
|
|
1509
|
+
*/
|
|
1274
1510
|
async #history(chatId, window, { reactions = true } = {}) {
|
|
1275
1511
|
await this.#connectOnce();
|
|
1276
1512
|
const session = this.#session();
|
|
@@ -1278,9 +1514,6 @@ export class MaxClient {
|
|
|
1278
1514
|
chatId,
|
|
1279
1515
|
...window,
|
|
1280
1516
|
getMessages: true,
|
|
1281
|
-
// The web client leaves this out. What MAX assumes when it is missing was not measured on a
|
|
1282
|
-
// chat with unread messages, and a guess wrong here marks the owner's chats read.
|
|
1283
|
-
interactive: false,
|
|
1284
1517
|
});
|
|
1285
1518
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
1286
1519
|
const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
|
|
@@ -1300,7 +1533,7 @@ export class MaxClient {
|
|
|
1300
1533
|
});
|
|
1301
1534
|
}
|
|
1302
1535
|
catch (error) {
|
|
1303
|
-
this.#
|
|
1536
|
+
this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
|
|
1304
1537
|
return messages;
|
|
1305
1538
|
}
|
|
1306
1539
|
}
|
|
@@ -1332,7 +1565,7 @@ export class MaxClient {
|
|
|
1332
1565
|
}
|
|
1333
1566
|
}
|
|
1334
1567
|
catch (error) {
|
|
1335
|
-
this.#
|
|
1568
|
+
this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
|
|
1336
1569
|
}
|
|
1337
1570
|
if (fetched.length > 0)
|
|
1338
1571
|
this.#cache?.people.upsert(fetched, "info");
|
|
@@ -1397,6 +1630,7 @@ export class MaxClient {
|
|
|
1397
1630
|
durationMs: Math.round(performance.now() - started),
|
|
1398
1631
|
outcome: "error",
|
|
1399
1632
|
errorCode: failure.code,
|
|
1633
|
+
...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
|
|
1400
1634
|
});
|
|
1401
1635
|
throw failure;
|
|
1402
1636
|
}
|
|
@@ -1412,9 +1646,23 @@ export class MaxClient {
|
|
|
1412
1646
|
});
|
|
1413
1647
|
const note = checkResponse(operation, answer);
|
|
1414
1648
|
if (note)
|
|
1415
|
-
this.#
|
|
1649
|
+
this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
|
|
1416
1650
|
return answer;
|
|
1417
1651
|
}
|
|
1652
|
+
/** The sentence to the person, and only the code to the log (`WarningEvent`). */
|
|
1653
|
+
/** A token from `MAX_TOKEN` belongs to whoever set it: nothing MAX hands back replaces what is stored. */
|
|
1654
|
+
#fromEnvironment() {
|
|
1655
|
+
try {
|
|
1656
|
+
return this.#store.tokenSource() === "environment";
|
|
1657
|
+
}
|
|
1658
|
+
catch {
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
#warnAbout(code, message, extra = {}) {
|
|
1663
|
+
this.#warn(message);
|
|
1664
|
+
this.#emit({ event: "warning", code, ...extra });
|
|
1665
|
+
}
|
|
1418
1666
|
/** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
|
|
1419
1667
|
#emit(event) {
|
|
1420
1668
|
try {
|
|
@@ -1472,11 +1720,11 @@ export class MaxClient {
|
|
|
1472
1720
|
* Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
|
|
1473
1721
|
* measured to be safe to repeat, and a repeated `create` is a second group.
|
|
1474
1722
|
*/
|
|
1475
|
-
async #changeChat(chatId, action, act) {
|
|
1723
|
+
async #changeChat(chatId, action, act, personIds) {
|
|
1476
1724
|
if (this.#offline)
|
|
1477
1725
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
|
|
1478
1726
|
try {
|
|
1479
|
-
this.#sends?.check(chatId, "chat", action);
|
|
1727
|
+
this.#sends?.check({ chatId, kind: "chat", action, ...(personIds ? { personIds } : {}) });
|
|
1480
1728
|
}
|
|
1481
1729
|
catch (error) {
|
|
1482
1730
|
this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
|
|
@@ -1508,8 +1756,8 @@ export class MaxClient {
|
|
|
1508
1756
|
}
|
|
1509
1757
|
async #updateMembers(reference, people, action, change) {
|
|
1510
1758
|
const chatId = await this.chats.resolve(reference);
|
|
1759
|
+
const userIds = await this.#personIds(people);
|
|
1511
1760
|
return this.#changeChat(chatId, action, async () => {
|
|
1512
|
-
const userIds = await this.#personIds(people);
|
|
1513
1761
|
const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
|
|
1514
1762
|
const chat = record(answer.chat);
|
|
1515
1763
|
return {
|
|
@@ -1517,7 +1765,7 @@ export class MaxClient {
|
|
|
1517
1765
|
people: userIds.length,
|
|
1518
1766
|
result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
|
|
1519
1767
|
};
|
|
1520
|
-
});
|
|
1768
|
+
}, action === "members.add" ? userIds : undefined);
|
|
1521
1769
|
}
|
|
1522
1770
|
/** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
|
|
1523
1771
|
async #personIds(references) {
|
|
@@ -1604,7 +1852,7 @@ export class MaxClient {
|
|
|
1604
1852
|
if (this.#offline)
|
|
1605
1853
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
|
|
1606
1854
|
try {
|
|
1607
|
-
this.#sends?.check(null, "account", action);
|
|
1855
|
+
this.#sends?.check({ chatId: null, kind: "account", action });
|
|
1608
1856
|
}
|
|
1609
1857
|
catch (error) {
|
|
1610
1858
|
this.#sends?.record({
|
|
@@ -1692,6 +1940,8 @@ export const ADMIN_RIGHTS = {
|
|
|
1692
1940
|
delete: 1024,
|
|
1693
1941
|
};
|
|
1694
1942
|
/** PyMax asks for this many; paging join requests is not known. */
|
|
1943
|
+
/** The most chats `max serve` holds from pushes before it logs in again instead. */
|
|
1944
|
+
const LIVE_CHATS = 10_000;
|
|
1695
1945
|
const JOIN_REQUESTS = 100;
|
|
1696
1946
|
/**
|
|
1697
1947
|
* A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
|
|
@@ -1736,6 +1986,8 @@ const checkedQuery = (query) => {
|
|
|
1736
1986
|
* names itself rather than as a wrong answer.
|
|
1737
1987
|
*/
|
|
1738
1988
|
const CONTACT_INFO_BATCH = 100;
|
|
1989
|
+
/** The most chats one `CHATS_LIST` answer was seen to hold (26, 2026-09-25). A page that full may have been cut. */
|
|
1990
|
+
const CHATS_PAGE_SEEN = 26;
|
|
1739
1991
|
/**
|
|
1740
1992
|
* When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
|
|
1741
1993
|
* low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
|
|
@@ -1753,10 +2005,20 @@ export const timeOfMessageId = (id) => {
|
|
|
1753
2005
|
* account rarely has that many chats change between two checks, and the rest are named, not lost.
|
|
1754
2006
|
*/
|
|
1755
2007
|
const INBOX_CHATS = 20;
|
|
2008
|
+
export const FIRST_TAB_SYNC = { folders: 0, calls: 0, assets: {} };
|
|
2009
|
+
const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
|
|
2010
|
+
const eventTime = (chat) => {
|
|
2011
|
+
const time = record(chat)?.lastEventTime;
|
|
2012
|
+
return typeof time === "number" ? time : 0;
|
|
2013
|
+
};
|
|
2014
|
+
/** Reactions on a message changed; PyMax calls it `NOTIF_MSG_REACTIONS_CHANGED` (tab recording 2026-09-25). */
|
|
2015
|
+
const REACTIONS_CHANGED = 155;
|
|
1756
2016
|
/** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
|
|
1757
2017
|
const NEW_MESSAGE = 128;
|
|
1758
2018
|
/** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
|
|
1759
2019
|
export const DELETE_AT_ONCE = 10;
|
|
2020
|
+
/** What web.max.ru asks for per page when history is scrolled up (`RES-9`). */
|
|
2021
|
+
export const BACKUP_PAGE = 30;
|
|
1760
2022
|
/** Read up to a point — by us on another device, or by somebody else. */
|
|
1761
2023
|
const READ_MARK = 130;
|
|
1762
2024
|
/** A chat changed; MAX sends it whole. */
|
|
@@ -1838,17 +2100,38 @@ const asCliError = (error) => {
|
|
|
1838
2100
|
if (error instanceof CliError)
|
|
1839
2101
|
return error;
|
|
1840
2102
|
if (error instanceof ProtocolError) {
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2103
|
+
const key = maxErrorKey(record(error.payload)?.error);
|
|
2104
|
+
const refused = asRefusal(error);
|
|
2105
|
+
return key === undefined
|
|
2106
|
+
? refused
|
|
2107
|
+
: new CliError(refused.code, refused.message, { ...refused.details, maxError: key });
|
|
1846
2108
|
}
|
|
1847
2109
|
const message = error instanceof Error ? error.message : String(error);
|
|
1848
2110
|
if (message.includes("did not answer"))
|
|
1849
2111
|
return new CliError("timeout", message);
|
|
1850
2112
|
return new CliError("network_error", message);
|
|
1851
2113
|
};
|
|
2114
|
+
const asRefusal = (error) => {
|
|
2115
|
+
const text = error.message.toLowerCase();
|
|
2116
|
+
// First: "too many auth attempts" is a limit, not a bad token. The words are claims —
|
|
2117
|
+
// `error.limit.violate` from PyMax #106, `rate_limit_exceeded` from GREEN-API — never measured here.
|
|
2118
|
+
if (LIMIT_WORDS.some((words) => text.includes(words))) {
|
|
2119
|
+
return new CliError("rate_limited", error.message, { operation: String(error.opcode) });
|
|
2120
|
+
}
|
|
2121
|
+
if (text.includes("token") || text.includes("auth")) {
|
|
2122
|
+
return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
|
|
2123
|
+
}
|
|
2124
|
+
return new CliError("provider_error", error.message, { operation: String(error.opcode) });
|
|
2125
|
+
};
|
|
2126
|
+
const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
|
|
2127
|
+
/** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
|
|
2128
|
+
export const refuseWhilePaused = (state) => {
|
|
2129
|
+
const until = loginPausedUntil(state);
|
|
2130
|
+
if (until === undefined)
|
|
2131
|
+
return;
|
|
2132
|
+
throw new CliError("rate_limited", `MAX refused this profile's last login for too many attempts; it will not try again before ${until} — ` +
|
|
2133
|
+
"logging in sooner is what keeps an account locked");
|
|
2134
|
+
};
|
|
1852
2135
|
const ownNames = (profile) => {
|
|
1853
2136
|
const contact = record(profile.contact) ?? profile;
|
|
1854
2137
|
const names = asArray(contact.names);
|
|
@@ -1872,10 +2155,15 @@ export const wirePhone = (typed) => {
|
|
|
1872
2155
|
return `+${digits}`;
|
|
1873
2156
|
};
|
|
1874
2157
|
/** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
|
|
2158
|
+
/** Measured 2026-09-25 (`MAX-57`, ASCII): 20 characters taken, 21 refused with `folder.validation.title.too-long`. */
|
|
2159
|
+
const FOLDER_TITLE_MAX = 20;
|
|
1875
2160
|
const folderTitle = (title) => {
|
|
1876
2161
|
const trimmed = title.trim();
|
|
1877
2162
|
if (trimmed === "")
|
|
1878
2163
|
throw new CliError("validation_error", "a folder needs a title");
|
|
2164
|
+
if ([...trimmed].length > FOLDER_TITLE_MAX) {
|
|
2165
|
+
throw new CliError("validation_error", `a folder title is at most ${FOLDER_TITLE_MAX} characters in MAX`);
|
|
2166
|
+
}
|
|
1879
2167
|
return trimmed;
|
|
1880
2168
|
};
|
|
1881
2169
|
const pickFolder = (reference, folders) => {
|