@leemour/max-cli 0.8.0 → 0.10.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/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.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 +38 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +177 -29
- 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 +97 -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/contacts.d.ts.map +1 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +1 -1
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +123 -14
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/folders.js.map +1 -1
- package/dist/commands/messages.js.map +1 -1
- package/dist/commands/paging.d.ts.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 +76 -0
- package/dist/commands/server.js.map +1 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +4 -0
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/skill.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/deadline.d.ts.map +1 -1
- package/dist/diagnose.d.ts +14 -1
- package/dist/diagnose.d.ts.map +1 -1
- package/dist/diagnose.js +13 -1
- 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.map +1 -1
- package/dist/download.d.ts.map +1 -1
- package/dist/export.d.ts +7 -4
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +13 -9
- package/dist/export.js.map +1 -1
- package/dist/generated/client.generated.d.ts +1 -0
- package/dist/generated/client.generated.d.ts.map +1 -1
- package/dist/generated/client.generated.js +1 -0
- package/dist/generated/client.generated.js.map +1 -1
- package/dist/generated/opcodes.generated.d.ts +1 -0
- package/dist/generated/opcodes.generated.d.ts.map +1 -1
- package/dist/generated/opcodes.generated.js +1 -0
- package/dist/generated/opcodes.generated.js.map +1 -1
- package/dist/generated/operations.generated.d.ts +21 -6
- package/dist/generated/operations.generated.d.ts.map +1 -1
- package/dist/generated/operations.generated.js +2 -1
- package/dist/generated/operations.generated.js.map +1 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/mcp/confirm.d.ts +2 -2
- package/dist/mcp/confirm.d.ts.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/output.d.ts.map +1 -1
- 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 +4 -0
- package/dist/program.js.map +1 -1
- package/dist/protocol/connection.d.ts +2 -1
- package/dist/protocol/connection.d.ts.map +1 -1
- package/dist/protocol/connection.js +45 -14
- package/dist/protocol/connection.js.map +1 -1
- package/dist/protocol/frame.d.ts +22 -14
- package/dist/protocol/frame.d.ts.map +1 -1
- package/dist/protocol/frame.js +116 -37
- package/dist/protocol/frame.js.map +1 -1
- package/dist/protocol/lz4.d.ts +15 -0
- package/dist/protocol/lz4.d.ts.map +1 -0
- package/dist/protocol/lz4.js +154 -0
- package/dist/protocol/lz4.js.map +1 -0
- package/dist/rendering/messages.d.ts.map +1 -1
- package/dist/rendering/messages.js.map +1 -1
- package/dist/report.d.ts +39 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +57 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts.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 +11 -0
- package/dist/runs/recording.d.ts.map +1 -1
- package/dist/runs/recording.js +70 -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.map +1 -1
- package/dist/sends/journal.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/sends/recipients.js.map +1 -1
- package/dist/server/lines.d.ts +4 -0
- package/dist/server/lines.d.ts.map +1 -1
- package/dist/server/lines.js +11 -2
- package/dist/server/lines.js.map +1 -1
- package/dist/server/server-connection.d.ts.map +1 -1
- package/dist/server/server-connection.js.map +1 -1
- package/dist/server/server.d.ts +10 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +40 -3
- package/dist/server/server.js.map +1 -1
- package/dist/server/start.d.ts +7 -1
- package/dist/server/start.d.ts.map +1 -1
- package/dist/server/start.js +17 -2
- 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 +9 -4
- package/dist/session/handshake.d.ts.map +1 -1
- package/dist/session/handshake.js +9 -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.map +1 -1
- package/dist/session/qr-terminal.d.ts.map +1 -1
- package/dist/session/store.d.ts +15 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +27 -0
- package/dist/session/store.js.map +1 -1
- package/dist/spec/check.d.ts.map +1 -1
- package/dist/spec/define.d.ts.map +1 -1
- package/dist/spec/identity.d.ts +43 -9
- package/dist/spec/identity.d.ts.map +1 -1
- package/dist/spec/identity.js +43 -13
- package/dist/spec/identity.js.map +1 -1
- package/dist/spec/index.d.ts.map +1 -1
- package/dist/spec/index.js +2 -1
- package/dist/spec/index.js.map +1 -1
- package/dist/spec/operations/chats.d.ts +9 -7
- package/dist/spec/operations/chats.d.ts.map +1 -1
- package/dist/spec/operations/chats.js +17 -12
- package/dist/spec/operations/chats.js.map +1 -1
- package/dist/spec/operations/session.d.ts +28 -4
- package/dist/spec/operations/session.d.ts.map +1 -1
- package/dist/spec/operations/session.js +45 -6
- package/dist/spec/operations/session.js.map +1 -1
- package/dist/spec/scalars.d.ts.map +1 -1
- package/dist/spec/scalars.js +2 -2
- package/dist/testing/mock-max.d.ts +5 -1
- package/dist/testing/mock-max.d.ts.map +1 -1
- package/dist/testing/mock-max.js +6 -3
- package/dist/testing/mock-max.js.map +1 -1
- package/dist/transcribe/index.d.ts.map +1 -1
- package/dist/transcribe/install.d.ts.map +1 -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.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 +12 -8
- package/skills/max-cli/SKILL.md +8 -1
package/dist/client.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
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";
|
|
14
16
|
import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
|
|
@@ -36,6 +38,7 @@ export class MaxClient {
|
|
|
36
38
|
#invoke = ((operation, request) => this.#send(operation, request));
|
|
37
39
|
#wire = wireClient(this.#invoke);
|
|
38
40
|
#login;
|
|
41
|
+
#chatsCut = false;
|
|
39
42
|
#previousCid = 0;
|
|
40
43
|
#people;
|
|
41
44
|
#merged;
|
|
@@ -215,7 +218,12 @@ export class MaxClient {
|
|
|
215
218
|
const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
|
|
216
219
|
if (!upTo)
|
|
217
220
|
throw new CliError("not_found", `chat ${chatId} has no messages to mark read`);
|
|
218
|
-
|
|
221
|
+
// The web client's mark is the read message's own time, not the moment of reading: a later
|
|
222
|
+
// one would mark newer messages read too (captured 2026-09-25, `RES-10`).
|
|
223
|
+
const mark = timeOfMessageId(upTo);
|
|
224
|
+
if (mark === undefined)
|
|
225
|
+
throw new CliError("validation_error", `"${upTo}" is not a message id`);
|
|
226
|
+
const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark });
|
|
219
227
|
this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
|
|
220
228
|
return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
|
|
221
229
|
}
|
|
@@ -483,7 +491,6 @@ export class MaxClient {
|
|
|
483
491
|
itemType: "DELAYED",
|
|
484
492
|
getChat: false,
|
|
485
493
|
getMessages: true,
|
|
486
|
-
interactive: false,
|
|
487
494
|
});
|
|
488
495
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
489
496
|
return asArray(answer.messages)
|
|
@@ -524,6 +531,52 @@ export class MaxClient {
|
|
|
524
531
|
const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 });
|
|
525
532
|
return { items: messages, hasMore: messages.length >= limit };
|
|
526
533
|
},
|
|
534
|
+
/**
|
|
535
|
+
* **Fills a chat's history backwards into the cache, page by page**, the way web.max.ru pages
|
|
536
|
+
* it when scrolled up (`RES-9`, captured 2026-09-25): 30 back from the time of the oldest
|
|
537
|
+
* message loaded, so each page repeats one message, and a shorter page is the chat's start.
|
|
538
|
+
*
|
|
539
|
+
* Stretches the cache already read completely are stepped over, so a second run continues
|
|
540
|
+
* where the first stopped. It ends at `since`, at `last` messages held, at the chat's start, or
|
|
541
|
+
* after `maxPages` — and on the first error, with no retry: what the limit error of MAX looks
|
|
542
|
+
* like is unknown, so every error is treated as one. Reactions are not read.
|
|
543
|
+
*/
|
|
544
|
+
backup: async (chatId, { since, last, maxPages, pause, onPage, }) => {
|
|
545
|
+
const cache = this.#cache;
|
|
546
|
+
if (!cache)
|
|
547
|
+
throw new CliError("not_found", "the local copy could not be opened, and a backup is kept there");
|
|
548
|
+
let cursor = Date.now();
|
|
549
|
+
let pages = 0;
|
|
550
|
+
for (;;) {
|
|
551
|
+
const held = heldWindows(cache.messages.ranges(chatId)).find((window) => window.from <= cursor && cursor <= window.to);
|
|
552
|
+
if (held)
|
|
553
|
+
cursor = held.from;
|
|
554
|
+
if (held?.from === 0)
|
|
555
|
+
return { pages, complete: true, reachedStart: true };
|
|
556
|
+
if (since !== undefined && cursor <= since)
|
|
557
|
+
return { pages, complete: true, reachedStart: false };
|
|
558
|
+
if (last !== undefined && cache.messages.count(chatId, cursor) >= last) {
|
|
559
|
+
return { pages, complete: true, reachedStart: false };
|
|
560
|
+
}
|
|
561
|
+
if (pages >= maxPages)
|
|
562
|
+
return { pages, complete: false, reachedStart: false };
|
|
563
|
+
if (pages > 0)
|
|
564
|
+
await pause();
|
|
565
|
+
const page = await this.#history(chatId, { from: cursor, backward: BACKUP_PAGE, forward: 0 }, { reactions: false });
|
|
566
|
+
pages += 1;
|
|
567
|
+
const oldest = page[0] ? Date.parse(page[0].timestamp) : cursor;
|
|
568
|
+
onPage?.({ number: pages, count: page.length, oldest: page[0]?.timestamp ?? null });
|
|
569
|
+
if (page.length < BACKUP_PAGE) {
|
|
570
|
+
if (page.length > 0 || pages > 1)
|
|
571
|
+
cache.messages.reachedStart(chatId, oldest);
|
|
572
|
+
return { pages, complete: true, reachedStart: true };
|
|
573
|
+
}
|
|
574
|
+
if (oldest >= cursor) {
|
|
575
|
+
throw new CliError("invalid_response", `MAX answered page ${pages} with nothing older than it was asked for`);
|
|
576
|
+
}
|
|
577
|
+
cursor = oldest;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
527
580
|
/**
|
|
528
581
|
* **One message and a window either side of it**, oldest first, the one asked for marked
|
|
529
582
|
* `anchor: true`.
|
|
@@ -876,7 +929,7 @@ export class MaxClient {
|
|
|
876
929
|
if (theirs.length > 0)
|
|
877
930
|
found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
|
|
878
931
|
}
|
|
879
|
-
return { mode: "unread", chats: found, skipped, partial: !this.#cache &&
|
|
932
|
+
return { mode: "unread", chats: found, skipped, partial: !this.#cache && this.#chatsCut };
|
|
880
933
|
},
|
|
881
934
|
/**
|
|
882
935
|
* Other people's messages in every chat that changed after `since`.
|
|
@@ -916,7 +969,7 @@ export class MaxClient {
|
|
|
916
969
|
until: new Date(until).toISOString(),
|
|
917
970
|
chats: found,
|
|
918
971
|
skipped,
|
|
919
|
-
partial: !this.#cache &&
|
|
972
|
+
partial: !this.#cache && this.#chatsCut && changed.length === chats.length,
|
|
920
973
|
};
|
|
921
974
|
},
|
|
922
975
|
};
|
|
@@ -972,12 +1025,7 @@ export class MaxClient {
|
|
|
972
1025
|
from: time,
|
|
973
1026
|
forward: 0,
|
|
974
1027
|
backward: 1,
|
|
975
|
-
forwardTime: 0,
|
|
976
|
-
backwardTime: 0,
|
|
977
|
-
itemType: "REGULAR",
|
|
978
|
-
getChat: false,
|
|
979
1028
|
getMessages: true,
|
|
980
|
-
interactive: false,
|
|
981
1029
|
});
|
|
982
1030
|
const found = asArray(answer.messages)
|
|
983
1031
|
.map((raw) => record(raw) ?? {})
|
|
@@ -1039,6 +1087,28 @@ export class MaxClient {
|
|
|
1039
1087
|
await this.#connectOnce();
|
|
1040
1088
|
await this.#wire.session.ping({ interactive: false });
|
|
1041
1089
|
},
|
|
1090
|
+
/**
|
|
1091
|
+
* What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
|
|
1092
|
+
* `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
|
|
1093
|
+
*/
|
|
1094
|
+
chatListShown: async ({ at, sessionId }) => {
|
|
1095
|
+
const viewerId = this.#store.readState().viewerId;
|
|
1096
|
+
if (!viewerId)
|
|
1097
|
+
return;
|
|
1098
|
+
await this.#connectOnce();
|
|
1099
|
+
await this.#wire.session.log({
|
|
1100
|
+
events: [
|
|
1101
|
+
{
|
|
1102
|
+
type: "NAV",
|
|
1103
|
+
userId: viewerId,
|
|
1104
|
+
time: at,
|
|
1105
|
+
sessionId,
|
|
1106
|
+
event: "GO",
|
|
1107
|
+
params: { action_id: 1, screen_to: 150, prev_time: 0, source_id: viewerId },
|
|
1108
|
+
},
|
|
1109
|
+
],
|
|
1110
|
+
});
|
|
1111
|
+
},
|
|
1042
1112
|
/**
|
|
1043
1113
|
* The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
|
|
1044
1114
|
* a command as that command's own login.
|
|
@@ -1128,11 +1198,21 @@ export class MaxClient {
|
|
|
1128
1198
|
async connect({ token: candidate } = {}) {
|
|
1129
1199
|
const token = candidate ?? this.#store.readToken();
|
|
1130
1200
|
if (!token) {
|
|
1201
|
+
const profile = this.#store.profile;
|
|
1202
|
+
// cli-core swallows a keyring that will not open, so the state file is the only witness: a
|
|
1203
|
+
// profile that has logged in lost its keyring, not its session, and another login would
|
|
1204
|
+
// register one more device for nothing (MAX-50, measured from cron 2026-09-25).
|
|
1205
|
+
if (this.#store.hasLoggedIn()) {
|
|
1206
|
+
throw new CliError("authentication_error", `no token found for profile "${profile}", although it has logged in on this machine — ` +
|
|
1207
|
+
`the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
|
|
1208
|
+
`\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
|
|
1209
|
+
}
|
|
1131
1210
|
// The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
|
|
1132
1211
|
// in under is the ordinary shape of this failure now that the first word is the profile.
|
|
1133
|
-
throw new CliError("authentication_error", `no session for profile "${
|
|
1212
|
+
throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
|
|
1134
1213
|
}
|
|
1135
1214
|
const state = this.#store.readState();
|
|
1215
|
+
refuseWhilePaused(state);
|
|
1136
1216
|
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
1137
1217
|
try {
|
|
1138
1218
|
await this.#connection.open();
|
|
@@ -1143,7 +1223,13 @@ export class MaxClient {
|
|
|
1143
1223
|
});
|
|
1144
1224
|
}
|
|
1145
1225
|
catch (error) {
|
|
1146
|
-
|
|
1226
|
+
const failure = asCliError(error);
|
|
1227
|
+
if (failure.code !== "rate_limited")
|
|
1228
|
+
throw failure;
|
|
1229
|
+
const paused = withLoginRefused(state);
|
|
1230
|
+
this.#store.writeState(paused);
|
|
1231
|
+
throw new CliError("rate_limited", `${failure.message} — this profile will not log in again before ${paused.loginPausedUntil}; ` +
|
|
1232
|
+
"logging in sooner is what keeps an account locked");
|
|
1147
1233
|
}
|
|
1148
1234
|
const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
|
|
1149
1235
|
// **Before `writeState`**, so a login we are about to refuse does not count itself or move
|
|
@@ -1155,14 +1241,42 @@ export class MaxClient {
|
|
|
1155
1241
|
`run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
|
|
1156
1242
|
}
|
|
1157
1243
|
this.#store.writeState({
|
|
1158
|
-
...state,
|
|
1244
|
+
...withoutLoginPause(state),
|
|
1159
1245
|
...(viewerId ? { viewerId } : {}),
|
|
1160
1246
|
logins: state.logins + 1,
|
|
1161
1247
|
lastLoginAt: new Date().toISOString(),
|
|
1162
1248
|
});
|
|
1163
1249
|
this.#keepRotatedToken(token);
|
|
1250
|
+
await this.#readRestOfChats();
|
|
1164
1251
|
this.#mergeLogin(viewerId);
|
|
1165
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
|
|
1255
|
+
* time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
|
|
1256
|
+
* same day: that answers exactly the chats after it, and one answer held 26. Only one request,
|
|
1257
|
+
* because the tab sends only one — `#chatsCut` says when that may not have been all.
|
|
1258
|
+
*
|
|
1259
|
+
* A refusal leaves the login's chats as they were: a shorter list beats a failed command.
|
|
1260
|
+
*/
|
|
1261
|
+
async #readRestOfChats() {
|
|
1262
|
+
this.#chatsCut = false;
|
|
1263
|
+
const session = this.#session();
|
|
1264
|
+
const chats = asArray(session.chats);
|
|
1265
|
+
const marker = record(chats.at(-1))?.lastEventTime;
|
|
1266
|
+
if (chats.length < LOGIN_CHATS || (typeof marker !== "number" && typeof marker !== "bigint"))
|
|
1267
|
+
return;
|
|
1268
|
+
try {
|
|
1269
|
+
const answer = await this.#wire.chats.list({ marker: Number(marker) });
|
|
1270
|
+
const known = new Set(chats.map((chat) => asId(record(chat)?.id)));
|
|
1271
|
+
const rest = asArray(answer.chats).filter((chat) => !known.has(asId(record(chat)?.id)));
|
|
1272
|
+
session.chats = [...chats, ...rest];
|
|
1273
|
+
this.#chatsCut = rest.length >= CHATS_PAGE_SEEN;
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
this.#chatsCut = true;
|
|
1277
|
+
this.#warnAbout("chats_partial", `only the newest ${chats.length} chats were read: ${reasonOf(error)}`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1166
1280
|
/**
|
|
1167
1281
|
* **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
|
|
1168
1282
|
* away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
|
|
@@ -1192,7 +1306,7 @@ export class MaxClient {
|
|
|
1192
1306
|
this.#store.writeToken(rotated);
|
|
1193
1307
|
}
|
|
1194
1308
|
catch (error) {
|
|
1195
|
-
this.#
|
|
1309
|
+
this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
|
|
1196
1310
|
}
|
|
1197
1311
|
}
|
|
1198
1312
|
/**
|
|
@@ -1238,7 +1352,7 @@ export class MaxClient {
|
|
|
1238
1352
|
});
|
|
1239
1353
|
}
|
|
1240
1354
|
catch (error) {
|
|
1241
|
-
this.#
|
|
1355
|
+
this.#warnAbout("cache_not_written", `the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
|
|
1242
1356
|
}
|
|
1243
1357
|
}
|
|
1244
1358
|
/** One page of contacts out of the store — the same query online and offline. */
|
|
@@ -1274,19 +1388,17 @@ export class MaxClient {
|
|
|
1274
1388
|
* no login, and is over before a socket would have finished its handshake. `connect()` stays
|
|
1275
1389
|
* public for the one command that must reach MAX to mean anything — starting a session.
|
|
1276
1390
|
*/
|
|
1277
|
-
/**
|
|
1391
|
+
/**
|
|
1392
|
+
* Never `CHAT_MARK`: reading history must not mark anything read (§19). Without `interactive`, as
|
|
1393
|
+
* the web client asks — measured 2026-09-25 on a channel with 8 unread: reading moved nothing.
|
|
1394
|
+
*/
|
|
1278
1395
|
async #history(chatId, window, { reactions = true } = {}) {
|
|
1279
1396
|
await this.#connectOnce();
|
|
1280
1397
|
const session = this.#session();
|
|
1281
1398
|
const answer = await this.#wire.chats.history({
|
|
1282
1399
|
chatId,
|
|
1283
1400
|
...window,
|
|
1284
|
-
forwardTime: 0,
|
|
1285
|
-
backwardTime: 0,
|
|
1286
|
-
itemType: "REGULAR",
|
|
1287
|
-
getChat: false,
|
|
1288
1401
|
getMessages: true,
|
|
1289
|
-
interactive: false,
|
|
1290
1402
|
});
|
|
1291
1403
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
1292
1404
|
const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
|
|
@@ -1306,7 +1418,7 @@ export class MaxClient {
|
|
|
1306
1418
|
});
|
|
1307
1419
|
}
|
|
1308
1420
|
catch (error) {
|
|
1309
|
-
this.#
|
|
1421
|
+
this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
|
|
1310
1422
|
return messages;
|
|
1311
1423
|
}
|
|
1312
1424
|
}
|
|
@@ -1338,7 +1450,7 @@ export class MaxClient {
|
|
|
1338
1450
|
}
|
|
1339
1451
|
}
|
|
1340
1452
|
catch (error) {
|
|
1341
|
-
this.#
|
|
1453
|
+
this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
|
|
1342
1454
|
}
|
|
1343
1455
|
if (fetched.length > 0)
|
|
1344
1456
|
this.#cache?.people.upsert(fetched, "info");
|
|
@@ -1403,6 +1515,7 @@ export class MaxClient {
|
|
|
1403
1515
|
durationMs: Math.round(performance.now() - started),
|
|
1404
1516
|
outcome: "error",
|
|
1405
1517
|
errorCode: failure.code,
|
|
1518
|
+
...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
|
|
1406
1519
|
});
|
|
1407
1520
|
throw failure;
|
|
1408
1521
|
}
|
|
@@ -1418,9 +1531,14 @@ export class MaxClient {
|
|
|
1418
1531
|
});
|
|
1419
1532
|
const note = checkResponse(operation, answer);
|
|
1420
1533
|
if (note)
|
|
1421
|
-
this.#
|
|
1534
|
+
this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
|
|
1422
1535
|
return answer;
|
|
1423
1536
|
}
|
|
1537
|
+
/** The sentence to the person, and only the code to the log (`WarningEvent`). */
|
|
1538
|
+
#warnAbout(code, message, extra = {}) {
|
|
1539
|
+
this.#warn(message);
|
|
1540
|
+
this.#emit({ event: "warning", code, ...extra });
|
|
1541
|
+
}
|
|
1424
1542
|
/** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
|
|
1425
1543
|
#emit(event) {
|
|
1426
1544
|
try {
|
|
@@ -1742,6 +1860,8 @@ const checkedQuery = (query) => {
|
|
|
1742
1860
|
* names itself rather than as a wrong answer.
|
|
1743
1861
|
*/
|
|
1744
1862
|
const CONTACT_INFO_BATCH = 100;
|
|
1863
|
+
/** The most chats one `CHATS_LIST` answer was seen to hold (26, 2026-09-25). A page that full may have been cut. */
|
|
1864
|
+
const CHATS_PAGE_SEEN = 26;
|
|
1745
1865
|
/**
|
|
1746
1866
|
* When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
|
|
1747
1867
|
* low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
|
|
@@ -1763,6 +1883,8 @@ const INBOX_CHATS = 20;
|
|
|
1763
1883
|
const NEW_MESSAGE = 128;
|
|
1764
1884
|
/** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
|
|
1765
1885
|
export const DELETE_AT_ONCE = 10;
|
|
1886
|
+
/** What web.max.ru asks for per page when history is scrolled up (`RES-9`). */
|
|
1887
|
+
export const BACKUP_PAGE = 30;
|
|
1766
1888
|
/** Read up to a point — by us on another device, or by somebody else. */
|
|
1767
1889
|
const READ_MARK = 130;
|
|
1768
1890
|
/** A chat changed; MAX sends it whole. */
|
|
@@ -1844,17 +1966,38 @@ const asCliError = (error) => {
|
|
|
1844
1966
|
if (error instanceof CliError)
|
|
1845
1967
|
return error;
|
|
1846
1968
|
if (error instanceof ProtocolError) {
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1969
|
+
const key = maxErrorKey(record(error.payload)?.error);
|
|
1970
|
+
const refused = asRefusal(error);
|
|
1971
|
+
return key === undefined
|
|
1972
|
+
? refused
|
|
1973
|
+
: new CliError(refused.code, refused.message, { ...refused.details, maxError: key });
|
|
1852
1974
|
}
|
|
1853
1975
|
const message = error instanceof Error ? error.message : String(error);
|
|
1854
1976
|
if (message.includes("did not answer"))
|
|
1855
1977
|
return new CliError("timeout", message);
|
|
1856
1978
|
return new CliError("network_error", message);
|
|
1857
1979
|
};
|
|
1980
|
+
const asRefusal = (error) => {
|
|
1981
|
+
const text = error.message.toLowerCase();
|
|
1982
|
+
// First: "too many auth attempts" is a limit, not a bad token. The words are claims —
|
|
1983
|
+
// `error.limit.violate` from PyMax #106, `rate_limit_exceeded` from GREEN-API — never measured here.
|
|
1984
|
+
if (LIMIT_WORDS.some((words) => text.includes(words))) {
|
|
1985
|
+
return new CliError("rate_limited", error.message, { operation: String(error.opcode) });
|
|
1986
|
+
}
|
|
1987
|
+
if (text.includes("token") || text.includes("auth")) {
|
|
1988
|
+
return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
|
|
1989
|
+
}
|
|
1990
|
+
return new CliError("provider_error", error.message, { operation: String(error.opcode) });
|
|
1991
|
+
};
|
|
1992
|
+
const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
|
|
1993
|
+
/** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
|
|
1994
|
+
export const refuseWhilePaused = (state) => {
|
|
1995
|
+
const until = loginPausedUntil(state);
|
|
1996
|
+
if (until === undefined)
|
|
1997
|
+
return;
|
|
1998
|
+
throw new CliError("rate_limited", `MAX refused this profile's last login for too many attempts; it will not try again before ${until} — ` +
|
|
1999
|
+
"logging in sooner is what keeps an account locked");
|
|
2000
|
+
};
|
|
1858
2001
|
const ownNames = (profile) => {
|
|
1859
2002
|
const contact = record(profile.contact) ?? profile;
|
|
1860
2003
|
const names = asArray(contact.names);
|
|
@@ -1878,10 +2021,15 @@ export const wirePhone = (typed) => {
|
|
|
1878
2021
|
return `+${digits}`;
|
|
1879
2022
|
};
|
|
1880
2023
|
/** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
|
|
2024
|
+
/** Measured 2026-09-25 (`MAX-57`, ASCII): 20 characters taken, 21 refused with `folder.validation.title.too-long`. */
|
|
2025
|
+
const FOLDER_TITLE_MAX = 20;
|
|
1881
2026
|
const folderTitle = (title) => {
|
|
1882
2027
|
const trimmed = title.trim();
|
|
1883
2028
|
if (trimmed === "")
|
|
1884
2029
|
throw new CliError("validation_error", "a folder needs a title");
|
|
2030
|
+
if ([...trimmed].length > FOLDER_TITLE_MAX) {
|
|
2031
|
+
throw new CliError("validation_error", `a folder title is at most ${FOLDER_TITLE_MAX} characters in MAX`);
|
|
2032
|
+
}
|
|
1885
2033
|
return trimmed;
|
|
1886
2034
|
};
|
|
1887
2035
|
const pickFolder = (reference, folders) => {
|