@leemour/max-cli 0.9.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 -23
- 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 +15 -1
- 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.map +1 -1
- package/dist/protocol/connection.js.map +1 -1
- package/dist/protocol/frame.d.ts.map +1 -1
- package/dist/protocol/lz4.d.ts.map +1 -1
- 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.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 +38 -3
- package/dist/server/server.js.map +1 -1
- package/dist/server/start.d.ts +1 -1
- package/dist/server/start.d.ts.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 +5 -2
- package/dist/session/handshake.d.ts.map +1 -1
- package/dist/session/handshake.js +7 -3
- 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 +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 +2 -1
- package/dist/spec/index.js.map +1 -1
- 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 +12 -9
- package/dist/spec/operations/chats.js.map +1 -1
- package/dist/spec/operations/session.d.ts +23 -0
- package/dist/spec/operations/session.d.ts.map +1 -1
- package/dist/spec/operations/session.js +38 -0
- package/dist/spec/operations/session.js.map +1 -1
- package/dist/spec/scalars.d.ts.map +1 -1
- package/dist/testing/mock-max.d.ts.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 +11 -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
|
};
|
|
@@ -973,7 +1026,6 @@ export class MaxClient {
|
|
|
973
1026
|
forward: 0,
|
|
974
1027
|
backward: 1,
|
|
975
1028
|
getMessages: true,
|
|
976
|
-
interactive: false,
|
|
977
1029
|
});
|
|
978
1030
|
const found = asArray(answer.messages)
|
|
979
1031
|
.map((raw) => record(raw) ?? {})
|
|
@@ -1035,6 +1087,28 @@ export class MaxClient {
|
|
|
1035
1087
|
await this.#connectOnce();
|
|
1036
1088
|
await this.#wire.session.ping({ interactive: false });
|
|
1037
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
|
+
},
|
|
1038
1112
|
/**
|
|
1039
1113
|
* The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
|
|
1040
1114
|
* a command as that command's own login.
|
|
@@ -1124,11 +1198,21 @@ export class MaxClient {
|
|
|
1124
1198
|
async connect({ token: candidate } = {}) {
|
|
1125
1199
|
const token = candidate ?? this.#store.readToken();
|
|
1126
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
|
+
}
|
|
1127
1210
|
// The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
|
|
1128
1211
|
// 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 "${
|
|
1212
|
+
throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
|
|
1130
1213
|
}
|
|
1131
1214
|
const state = this.#store.readState();
|
|
1215
|
+
refuseWhilePaused(state);
|
|
1132
1216
|
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
1133
1217
|
try {
|
|
1134
1218
|
await this.#connection.open();
|
|
@@ -1139,7 +1223,13 @@ export class MaxClient {
|
|
|
1139
1223
|
});
|
|
1140
1224
|
}
|
|
1141
1225
|
catch (error) {
|
|
1142
|
-
|
|
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");
|
|
1143
1233
|
}
|
|
1144
1234
|
const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
|
|
1145
1235
|
// **Before `writeState`**, so a login we are about to refuse does not count itself or move
|
|
@@ -1151,14 +1241,42 @@ export class MaxClient {
|
|
|
1151
1241
|
`run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
|
|
1152
1242
|
}
|
|
1153
1243
|
this.#store.writeState({
|
|
1154
|
-
...state,
|
|
1244
|
+
...withoutLoginPause(state),
|
|
1155
1245
|
...(viewerId ? { viewerId } : {}),
|
|
1156
1246
|
logins: state.logins + 1,
|
|
1157
1247
|
lastLoginAt: new Date().toISOString(),
|
|
1158
1248
|
});
|
|
1159
1249
|
this.#keepRotatedToken(token);
|
|
1250
|
+
await this.#readRestOfChats();
|
|
1160
1251
|
this.#mergeLogin(viewerId);
|
|
1161
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
|
+
}
|
|
1162
1280
|
/**
|
|
1163
1281
|
* **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
|
|
1164
1282
|
* away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
|
|
@@ -1188,7 +1306,7 @@ export class MaxClient {
|
|
|
1188
1306
|
this.#store.writeToken(rotated);
|
|
1189
1307
|
}
|
|
1190
1308
|
catch (error) {
|
|
1191
|
-
this.#
|
|
1309
|
+
this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
|
|
1192
1310
|
}
|
|
1193
1311
|
}
|
|
1194
1312
|
/**
|
|
@@ -1234,7 +1352,7 @@ export class MaxClient {
|
|
|
1234
1352
|
});
|
|
1235
1353
|
}
|
|
1236
1354
|
catch (error) {
|
|
1237
|
-
this.#
|
|
1355
|
+
this.#warnAbout("cache_not_written", `the local record did not take this login, so nothing was kept from it: ${reasonOf(error)}`);
|
|
1238
1356
|
}
|
|
1239
1357
|
}
|
|
1240
1358
|
/** One page of contacts out of the store — the same query online and offline. */
|
|
@@ -1270,7 +1388,10 @@ export class MaxClient {
|
|
|
1270
1388
|
* no login, and is over before a socket would have finished its handshake. `connect()` stays
|
|
1271
1389
|
* public for the one command that must reach MAX to mean anything — starting a session.
|
|
1272
1390
|
*/
|
|
1273
|
-
/**
|
|
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
|
+
*/
|
|
1274
1395
|
async #history(chatId, window, { reactions = true } = {}) {
|
|
1275
1396
|
await this.#connectOnce();
|
|
1276
1397
|
const session = this.#session();
|
|
@@ -1278,9 +1399,6 @@ export class MaxClient {
|
|
|
1278
1399
|
chatId,
|
|
1279
1400
|
...window,
|
|
1280
1401
|
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
1402
|
});
|
|
1285
1403
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
1286
1404
|
const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
|
|
@@ -1300,7 +1418,7 @@ export class MaxClient {
|
|
|
1300
1418
|
});
|
|
1301
1419
|
}
|
|
1302
1420
|
catch (error) {
|
|
1303
|
-
this.#
|
|
1421
|
+
this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
|
|
1304
1422
|
return messages;
|
|
1305
1423
|
}
|
|
1306
1424
|
}
|
|
@@ -1332,7 +1450,7 @@ export class MaxClient {
|
|
|
1332
1450
|
}
|
|
1333
1451
|
}
|
|
1334
1452
|
catch (error) {
|
|
1335
|
-
this.#
|
|
1453
|
+
this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
|
|
1336
1454
|
}
|
|
1337
1455
|
if (fetched.length > 0)
|
|
1338
1456
|
this.#cache?.people.upsert(fetched, "info");
|
|
@@ -1397,6 +1515,7 @@ export class MaxClient {
|
|
|
1397
1515
|
durationMs: Math.round(performance.now() - started),
|
|
1398
1516
|
outcome: "error",
|
|
1399
1517
|
errorCode: failure.code,
|
|
1518
|
+
...(typeof failure.details.maxError === "string" ? { maxError: failure.details.maxError } : {}),
|
|
1400
1519
|
});
|
|
1401
1520
|
throw failure;
|
|
1402
1521
|
}
|
|
@@ -1412,9 +1531,14 @@ export class MaxClient {
|
|
|
1412
1531
|
});
|
|
1413
1532
|
const note = checkResponse(operation, answer);
|
|
1414
1533
|
if (note)
|
|
1415
|
-
this.#
|
|
1534
|
+
this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
|
|
1416
1535
|
return answer;
|
|
1417
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
|
+
}
|
|
1418
1542
|
/** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
|
|
1419
1543
|
#emit(event) {
|
|
1420
1544
|
try {
|
|
@@ -1736,6 +1860,8 @@ const checkedQuery = (query) => {
|
|
|
1736
1860
|
* names itself rather than as a wrong answer.
|
|
1737
1861
|
*/
|
|
1738
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;
|
|
1739
1865
|
/**
|
|
1740
1866
|
* When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
|
|
1741
1867
|
* low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
|
|
@@ -1757,6 +1883,8 @@ const INBOX_CHATS = 20;
|
|
|
1757
1883
|
const NEW_MESSAGE = 128;
|
|
1758
1884
|
/** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
|
|
1759
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;
|
|
1760
1888
|
/** Read up to a point — by us on another device, or by somebody else. */
|
|
1761
1889
|
const READ_MARK = 130;
|
|
1762
1890
|
/** A chat changed; MAX sends it whole. */
|
|
@@ -1838,17 +1966,38 @@ const asCliError = (error) => {
|
|
|
1838
1966
|
if (error instanceof CliError)
|
|
1839
1967
|
return error;
|
|
1840
1968
|
if (error instanceof ProtocolError) {
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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 });
|
|
1846
1974
|
}
|
|
1847
1975
|
const message = error instanceof Error ? error.message : String(error);
|
|
1848
1976
|
if (message.includes("did not answer"))
|
|
1849
1977
|
return new CliError("timeout", message);
|
|
1850
1978
|
return new CliError("network_error", message);
|
|
1851
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
|
+
};
|
|
1852
2001
|
const ownNames = (profile) => {
|
|
1853
2002
|
const contact = record(profile.contact) ?? profile;
|
|
1854
2003
|
const names = asArray(contact.names);
|
|
@@ -1872,10 +2021,15 @@ export const wirePhone = (typed) => {
|
|
|
1872
2021
|
return `+${digits}`;
|
|
1873
2022
|
};
|
|
1874
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;
|
|
1875
2026
|
const folderTitle = (title) => {
|
|
1876
2027
|
const trimmed = title.trim();
|
|
1877
2028
|
if (trimmed === "")
|
|
1878
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
|
+
}
|
|
1879
2033
|
return trimmed;
|
|
1880
2034
|
};
|
|
1881
2035
|
const pickFolder = (reference, folders) => {
|