@leemour/max-cli 0.5.0 → 0.7.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 +22 -5
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +4 -4
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/store.d.ts.map +1 -1
- package/dist/cache/store.js +1 -0
- package/dist/cache/store.js.map +1 -1
- package/dist/client.d.ts +230 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +959 -78
- package/dist/client.js.map +1 -1
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +71 -3
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/chats.d.ts.map +1 -1
- package/dist/commands/chats.js +159 -0
- package/dist/commands/chats.js.map +1 -1
- package/dist/commands/commands.d.ts +8 -0
- package/dist/commands/commands.d.ts.map +1 -0
- package/dist/commands/commands.js +36 -0
- package/dist/commands/commands.js.map +1 -0
- package/dist/commands/complete.d.ts +10 -0
- package/dist/commands/complete.d.ts.map +1 -0
- package/dist/commands/complete.js +89 -0
- package/dist/commands/complete.js.map +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +6 -2
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/contacts.d.ts +3 -0
- package/dist/commands/contacts.d.ts.map +1 -1
- package/dist/commands/contacts.js +85 -0
- package/dist/commands/contacts.js.map +1 -1
- package/dist/commands/context.d.ts +26 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +47 -6
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/folders.d.ts +4 -0
- package/dist/commands/folders.d.ts.map +1 -0
- package/dist/commands/folders.js +76 -0
- package/dist/commands/folders.js.map +1 -0
- package/dist/commands/inbox.d.ts +14 -0
- package/dist/commands/inbox.d.ts.map +1 -0
- package/dist/commands/inbox.js +90 -0
- package/dist/commands/inbox.js.map +1 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +17 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/messages.d.ts.map +1 -1
- package/dist/commands/messages.js +90 -4
- package/dist/commands/messages.js.map +1 -1
- package/dist/commands/reactions.d.ts +3 -0
- package/dist/commands/reactions.d.ts.map +1 -0
- package/dist/commands/reactions.js +43 -0
- package/dist/commands/reactions.js.map +1 -0
- package/dist/commands/recipients.d.ts +9 -0
- package/dist/commands/recipients.d.ts.map +1 -0
- package/dist/commands/recipients.js +68 -0
- package/dist/commands/recipients.js.map +1 -0
- package/dist/commands/sends.d.ts +4 -0
- package/dist/commands/sends.d.ts.map +1 -0
- package/dist/commands/sends.js +21 -0
- package/dist/commands/sends.js.map +1 -0
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +50 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +84 -16
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +2 -1
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +53 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/watch.d.ts +9 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +68 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/config.d.ts +24 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -4
- package/dist/config.js.map +1 -1
- package/dist/domain/map.d.ts +10 -1
- package/dist/domain/map.d.ts.map +1 -1
- package/dist/domain/map.js +51 -1
- package/dist/domain/map.js.map +1 -1
- package/dist/domain/models.d.ts +80 -0
- package/dist/domain/models.d.ts.map +1 -1
- package/dist/domain/models.js.map +1 -1
- package/dist/generated/client.generated.d.ts +36 -0
- package/dist/generated/client.generated.d.ts.map +1 -1
- package/dist/generated/client.generated.js +36 -0
- package/dist/generated/client.generated.js.map +1 -1
- package/dist/generated/opcodes.generated.d.ts +37 -5
- package/dist/generated/opcodes.generated.d.ts.map +1 -1
- package/dist/generated/opcodes.generated.js +37 -5
- package/dist/generated/opcodes.generated.js.map +1 -1
- package/dist/generated/operations.generated.d.ts +245 -3
- package/dist/generated/operations.generated.d.ts.map +1 -1
- package/dist/generated/operations.generated.js +36 -4
- package/dist/generated/operations.generated.js.map +1 -1
- package/dist/markdown.d.ts +18 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +52 -0
- package/dist/markdown.js.map +1 -0
- package/dist/mcp/confirm.d.ts +24 -0
- package/dist/mcp/confirm.d.ts.map +1 -0
- package/dist/mcp/confirm.js +53 -0
- package/dist/mcp/confirm.js.map +1 -0
- package/dist/mcp/instructions.d.ts +10 -0
- package/dist/mcp/instructions.d.ts.map +1 -0
- package/dist/mcp/instructions.js +25 -0
- package/dist/mcp/instructions.js.map +1 -0
- package/dist/mcp/server.d.ts +23 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/session.d.ts +28 -0
- package/dist/mcp/session.d.ts.map +1 -0
- package/dist/mcp/session.js +91 -0
- package/dist/mcp/session.js.map +1 -0
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +274 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +27 -0
- package/dist/program.js.map +1 -1
- package/dist/protocol/connection.d.ts +10 -8
- package/dist/protocol/connection.d.ts.map +1 -1
- package/dist/protocol/connection.js +64 -7
- package/dist/protocol/connection.js.map +1 -1
- package/dist/protocol/frame.d.ts +2 -1
- package/dist/protocol/frame.d.ts.map +1 -1
- package/dist/protocol/frame.js.map +1 -1
- package/dist/rendering/messages.d.ts.map +1 -1
- package/dist/rendering/messages.js +21 -7
- package/dist/rendering/messages.js.map +1 -1
- package/dist/resolve.d.ts +15 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +48 -0
- package/dist/resolve.js.map +1 -0
- package/dist/sends/guard.d.ts +27 -0
- package/dist/sends/guard.d.ts.map +1 -0
- package/dist/sends/guard.js +52 -0
- package/dist/sends/guard.js.map +1 -0
- package/dist/sends/journal.d.ts +42 -0
- package/dist/sends/journal.d.ts.map +1 -0
- package/dist/sends/journal.js +40 -0
- package/dist/sends/journal.js.map +1 -0
- package/dist/sends/recipients.d.ts +29 -0
- package/dist/sends/recipients.d.ts.map +1 -0
- package/dist/sends/recipients.js +62 -0
- package/dist/sends/recipients.js.map +1 -0
- package/dist/server/lines.d.ts +9 -0
- package/dist/server/lines.d.ts.map +1 -0
- package/dist/server/lines.js +25 -0
- package/dist/server/lines.js.map +1 -0
- package/dist/server/server-connection.d.ts +35 -0
- package/dist/server/server-connection.d.ts.map +1 -0
- package/dist/server/server-connection.js +195 -0
- package/dist/server/server-connection.js.map +1 -0
- package/dist/server/server.d.ts +62 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +297 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/start.d.ts +12 -0
- package/dist/server/start.d.ts.map +1 -0
- package/dist/server/start.js +41 -0
- package/dist/server/start.js.map +1 -0
- package/dist/server/subscribe.d.ts +9 -0
- package/dist/server/subscribe.d.ts.map +1 -0
- package/dist/server/subscribe.js +26 -0
- package/dist/server/subscribe.js.map +1 -0
- package/dist/session/adopt.d.ts.map +1 -1
- package/dist/session/adopt.js +7 -6
- package/dist/session/adopt.js.map +1 -1
- package/dist/session/browser.d.ts +55 -0
- package/dist/session/browser.d.ts.map +1 -0
- package/dist/session/browser.js +319 -0
- package/dist/session/browser.js.map +1 -0
- package/dist/session/handshake.d.ts +2 -0
- package/dist/session/handshake.d.ts.map +1 -1
- package/dist/session/handshake.js +3 -1
- package/dist/session/handshake.js.map +1 -1
- package/dist/session/login.d.ts +20 -0
- package/dist/session/login.d.ts.map +1 -0
- package/dist/session/login.js +66 -0
- package/dist/session/login.js.map +1 -0
- package/dist/session/prompt.d.ts +3 -1
- package/dist/session/prompt.d.ts.map +1 -1
- package/dist/session/prompt.js +2 -2
- package/dist/session/prompt.js.map +1 -1
- package/dist/session/qr-terminal.d.ts +9 -0
- package/dist/session/qr-terminal.d.ts.map +1 -0
- package/dist/session/qr-terminal.js +34 -0
- package/dist/session/qr-terminal.js.map +1 -0
- package/dist/session/store.d.ts +4 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +6 -0
- package/dist/session/store.js.map +1 -1
- package/dist/spec/define.d.ts.map +1 -1
- package/dist/spec/define.js +14 -1
- package/dist/spec/define.js.map +1 -1
- package/dist/spec/index.d.ts.map +1 -1
- package/dist/spec/index.js +38 -5
- package/dist/spec/index.js.map +1 -1
- package/dist/spec/operations/account.d.ts +20 -0
- package/dist/spec/operations/account.d.ts.map +1 -0
- package/dist/spec/operations/account.js +62 -0
- package/dist/spec/operations/account.js.map +1 -0
- package/dist/spec/operations/chats.d.ts +54 -0
- package/dist/spec/operations/chats.d.ts.map +1 -1
- package/dist/spec/operations/chats.js +121 -0
- package/dist/spec/operations/chats.js.map +1 -1
- package/dist/spec/operations/contacts.d.ts +19 -1
- package/dist/spec/operations/contacts.d.ts.map +1 -1
- package/dist/spec/operations/contacts.js +49 -6
- package/dist/spec/operations/contacts.js.map +1 -1
- package/dist/spec/operations/folders.d.ts +24 -0
- package/dist/spec/operations/folders.d.ts.map +1 -0
- package/dist/spec/operations/folders.js +64 -0
- package/dist/spec/operations/folders.js.map +1 -0
- package/dist/spec/operations/login.d.ts +64 -0
- package/dist/spec/operations/login.d.ts.map +1 -0
- package/dist/spec/operations/login.js +99 -0
- package/dist/spec/operations/login.js.map +1 -0
- package/dist/spec/operations/messages.d.ts +67 -3
- package/dist/spec/operations/messages.d.ts.map +1 -1
- package/dist/spec/operations/messages.js +126 -13
- package/dist/spec/operations/messages.js.map +1 -1
- package/dist/spec/operations/session.d.ts +7 -0
- package/dist/spec/operations/session.d.ts.map +1 -1
- package/dist/spec/operations/session.js +17 -0
- package/dist/spec/operations/session.js.map +1 -1
- package/dist/spec/operations/uploads.d.ts +18 -0
- package/dist/spec/operations/uploads.d.ts.map +1 -0
- package/dist/spec/operations/uploads.js +35 -0
- package/dist/spec/operations/uploads.js.map +1 -0
- package/dist/testing/mock-max.d.ts +15 -4
- package/dist/testing/mock-max.d.ts.map +1 -1
- package/dist/testing/mock-max.js +16 -3
- package/dist/testing/mock-max.js.map +1 -1
- package/dist/testing/sandbox.js +3 -0
- package/dist/testing/sandbox.js.map +1 -1
- package/dist/update.d.ts +36 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +70 -0
- package/dist/update.js.map +1 -0
- package/dist/upload.d.ts +10 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +61 -0
- package/dist/upload.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +16 -3
- package/skills/max-cli/SKILL.md +23 -3
package/dist/client.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { CliError } from "@leemour/cli-core";
|
|
2
|
-
import { namesFrom, toChat, toContact, toMessage, toProfile } from "./domain/map.js";
|
|
3
|
-
import { timeOfMessageId, } from "./domain/models.js";
|
|
2
|
+
import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
|
|
4
3
|
import { wireClient } from "./generated/client.generated.js";
|
|
4
|
+
import { parseMarkdown } from "./markdown.js";
|
|
5
5
|
import { asFirstWord } from "./profile.js";
|
|
6
6
|
import { Connection, ProtocolError } from "./protocol/connection.js";
|
|
7
|
+
import { asId } from "./protocol/frame.js";
|
|
8
|
+
import { isId, pickChat, pickPerson } from "./resolve.js";
|
|
7
9
|
import { countsIn, idsOf } from "./runs/events.js";
|
|
8
|
-
import { startSession } from "./session/handshake.js";
|
|
10
|
+
import { LOGIN_CHATS, startSession } from "./session/handshake.js";
|
|
11
|
+
import { tokenByQr } from "./session/login.js";
|
|
12
|
+
import { WEB_USER_AGENT } from "./spec/identity.js";
|
|
9
13
|
import { buildRequest, checkResponse } from "./spec/index.js";
|
|
14
|
+
import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
|
|
10
15
|
/**
|
|
11
16
|
* **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
|
|
12
17
|
* opcodes and the frames stop here, so a different transport underneath changes this file and
|
|
@@ -22,26 +27,92 @@ import { buildRequest, checkResponse } from "./spec/index.js";
|
|
|
22
27
|
export class MaxClient {
|
|
23
28
|
#store;
|
|
24
29
|
#connection;
|
|
30
|
+
#fullLogin;
|
|
25
31
|
#warn;
|
|
26
32
|
#cache;
|
|
27
33
|
#offline;
|
|
28
34
|
#events;
|
|
35
|
+
#sends;
|
|
29
36
|
#invoke = ((operation, request) => this.#send(operation, request));
|
|
30
37
|
#wire = wireClient(this.#invoke);
|
|
31
38
|
#login;
|
|
32
39
|
#previousCid = 0;
|
|
33
40
|
#people;
|
|
34
41
|
#merged;
|
|
35
|
-
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events }) {
|
|
42
|
+
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, }) {
|
|
36
43
|
this.#store = store;
|
|
44
|
+
this.#fullLogin = fullLogin;
|
|
37
45
|
this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
|
|
38
46
|
this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
39
47
|
this.#cache = cache;
|
|
40
48
|
this.#offline = offline;
|
|
41
49
|
this.#events = events ?? (() => { });
|
|
50
|
+
this.#sends = sends;
|
|
42
51
|
}
|
|
43
52
|
account = {
|
|
44
|
-
me: () =>
|
|
53
|
+
me: async () => {
|
|
54
|
+
await this.#connectOnce();
|
|
55
|
+
return toProfile(record(this.#session().profile) ?? {});
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* Changes the profile everyone sees. **The name goes out whole**: the web client always sends
|
|
59
|
+
* the current first name, so a change to the description alone keeps it from the login.
|
|
60
|
+
*/
|
|
61
|
+
update: (change) => this.#change("profile", async () => {
|
|
62
|
+
const current = ownNames(record(this.#session().profile) ?? {});
|
|
63
|
+
const firstName = change.firstName ?? current.firstName;
|
|
64
|
+
if (!firstName)
|
|
65
|
+
throw new CliError("validation_error", "MAX needs a first name, and the profile has none — pass --first-name");
|
|
66
|
+
const lastName = change.lastName ?? current.lastName;
|
|
67
|
+
const answer = await this.#wire.account.update({
|
|
68
|
+
firstName,
|
|
69
|
+
...(lastName === undefined ? {} : { lastName }),
|
|
70
|
+
...(change.description === undefined ? {} : { description: change.description }),
|
|
71
|
+
});
|
|
72
|
+
return toProfile(record(answer.profile) ?? {});
|
|
73
|
+
}),
|
|
74
|
+
sessions: async () => {
|
|
75
|
+
if (this.#offline)
|
|
76
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded, and sessions are not recorded");
|
|
77
|
+
await this.#connectOnce();
|
|
78
|
+
return asArray((await this.#wire.account.sessions({})).sessions).map(toSession);
|
|
79
|
+
},
|
|
80
|
+
/**
|
|
81
|
+
* Logs every other device out — the owner's phone included — and keeps this one.
|
|
82
|
+
*
|
|
83
|
+
* PyMax reads a replacement token from the answer and the web client reads nothing (`RISK-25`),
|
|
84
|
+
* so both are handled: a token that came back is stored before anything is reported, and then
|
|
85
|
+
* the session is asked for once more, so a MAX that ended this one too says so here rather
|
|
86
|
+
* than on the next command.
|
|
87
|
+
*/
|
|
88
|
+
endOtherSessions: () => this.#change("sessions-end", async (done) => {
|
|
89
|
+
const answer = await this.#wire.account.closeSessions({});
|
|
90
|
+
// The other devices are out from here on, whatever follows fails: the journal says so.
|
|
91
|
+
done();
|
|
92
|
+
const token = answer.token;
|
|
93
|
+
if (typeof token === "string" && token !== "") {
|
|
94
|
+
try {
|
|
95
|
+
this.#store.writeToken(token);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw new CliError("configuration_error", `the other sessions are ended, but the new token for this one could not be saved: ${reasonOf(error)} — run \`max session start\``);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return asArray((await this.#wire.account.sessions({})).sessions).map(toSession);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new CliError("authentication_error", `the other sessions are ended, and MAX no longer answers this one (${asCliError(error).message}) — run \`max session start\``);
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Obtaining a token rather than using one. The token comes back to the caller, unstored and not yet
|
|
111
|
+
* tried: `adoptToken` logs in with it on a connection of its own before the keyring sees it, so
|
|
112
|
+
* a login that went wrong halfway cannot replace a working session.
|
|
113
|
+
*/
|
|
114
|
+
login = {
|
|
115
|
+
byQr: (options) => this.#beforeLogin(() => tokenByQr(this.#wire, options)),
|
|
45
116
|
};
|
|
46
117
|
chats = {
|
|
47
118
|
/**
|
|
@@ -115,6 +186,113 @@ export class MaxClient {
|
|
|
115
186
|
const cache = this.#cache;
|
|
116
187
|
return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
|
|
117
188
|
},
|
|
189
|
+
/** What a link leads to, without joining it. */
|
|
190
|
+
inspect: async (link) => {
|
|
191
|
+
if (this.#offline)
|
|
192
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot read a link");
|
|
193
|
+
const wire = wireLink(link);
|
|
194
|
+
await this.#connectOnce();
|
|
195
|
+
return toGroupCard(record((await this.#wire.chats.linkInfo({ link: wire })).chat) ?? {});
|
|
196
|
+
},
|
|
197
|
+
join: (link) => {
|
|
198
|
+
const wire = wireLink(link);
|
|
199
|
+
return this.#changeChat(null, "join", async () => {
|
|
200
|
+
const chat = toGroupCard(record((await this.#wire.chats.join({ link: wire })).chat) ?? {});
|
|
201
|
+
return { chatId: chat.id, result: chat };
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
leave: async (reference) => {
|
|
205
|
+
const chatId = await this.chats.resolve(reference);
|
|
206
|
+
return this.#changeChat(chatId, "leave", async () => {
|
|
207
|
+
await this.#wire.chats.leave({ chatId });
|
|
208
|
+
return { chatId, result: { chatId, action: "leave", people: [], chat: null } };
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
|
|
213
|
+
* whether MAX deduplicates a creation by `cid` is not measured.
|
|
214
|
+
*/
|
|
215
|
+
create: (title, people = []) => this.#changeChat(null, "create", async () => {
|
|
216
|
+
const userIds = await this.#personIds(people);
|
|
217
|
+
const answer = await this.#wire.messages.send({
|
|
218
|
+
message: {
|
|
219
|
+
cid: this.#nextCid(),
|
|
220
|
+
attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
|
|
221
|
+
},
|
|
222
|
+
notify: true,
|
|
223
|
+
});
|
|
224
|
+
const chat = toGroupCard(record(answer.chat) ?? {});
|
|
225
|
+
return { chatId: chat.id, result: chat, people: userIds.length };
|
|
226
|
+
}),
|
|
227
|
+
members: {
|
|
228
|
+
add: (reference, people, { history = true } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
|
|
229
|
+
remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
|
|
230
|
+
},
|
|
231
|
+
admins: {
|
|
232
|
+
add: (reference, person, rights) => this.#updateMembers(reference, [person], "admins.add", {
|
|
233
|
+
operation: "add",
|
|
234
|
+
type: "ADMIN",
|
|
235
|
+
permissions: rights.reduce((sum, right) => sum | ADMIN_RIGHTS[right], 0),
|
|
236
|
+
}),
|
|
237
|
+
remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
|
|
238
|
+
},
|
|
239
|
+
requests: {
|
|
240
|
+
list: async (reference) => {
|
|
241
|
+
if (this.#offline)
|
|
242
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; join requests never are");
|
|
243
|
+
const chatId = await this.chats.resolve(reference);
|
|
244
|
+
await this.#connectOnce();
|
|
245
|
+
const answer = await this.#wire.chats.members({ chatId, type: "JOIN_REQUEST", count: JOIN_REQUESTS });
|
|
246
|
+
return asArray(answer.members).map((member) => toContact(record(member.contact) ?? {}));
|
|
247
|
+
},
|
|
248
|
+
accept: (reference, people) => this.#updateMembers(reference, people, "requests.accept", {
|
|
249
|
+
operation: "add",
|
|
250
|
+
type: "JOIN_REQUEST",
|
|
251
|
+
showHistory: true,
|
|
252
|
+
}),
|
|
253
|
+
decline: (reference, people) => this.#updateMembers(reference, people, "requests.decline", { operation: "remove", type: "JOIN_REQUEST" }),
|
|
254
|
+
},
|
|
255
|
+
update: async (reference, { title, description }) => {
|
|
256
|
+
if (title === undefined && description === undefined) {
|
|
257
|
+
throw new CliError("validation_error", "nothing to change — give --title, --description or both");
|
|
258
|
+
}
|
|
259
|
+
const chatId = await this.chats.resolve(reference);
|
|
260
|
+
return this.#changeChat(chatId, "update", async () => {
|
|
261
|
+
const answer = await this.#wire.chats.update({
|
|
262
|
+
chatId,
|
|
263
|
+
...(title === undefined ? {} : { theme: title }),
|
|
264
|
+
...(description === undefined ? {} : { description }),
|
|
265
|
+
});
|
|
266
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
/** With no changes, reads the settings the login carried; nothing is sent. */
|
|
270
|
+
settings: async (reference, changes = {}) => {
|
|
271
|
+
if (this.#offline)
|
|
272
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; settings never are");
|
|
273
|
+
const chatId = await this.chats.resolve(reference);
|
|
274
|
+
const entries = Object.entries(changes).filter(([, value]) => value !== undefined);
|
|
275
|
+
if (entries.length === 0) {
|
|
276
|
+
await this.#connectOnce();
|
|
277
|
+
const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
|
|
278
|
+
if (!raw)
|
|
279
|
+
throw new CliError("not_found", `no chat ${chatId} among this account's chats`);
|
|
280
|
+
return toGroupCard(raw);
|
|
281
|
+
}
|
|
282
|
+
const options = Object.fromEntries(entries.map(([ours, value]) => [SETTING_FLAGS[ours], value]));
|
|
283
|
+
return this.#changeChat(chatId, "settings", async () => {
|
|
284
|
+
const answer = await this.#wire.chats.update({ chatId, options });
|
|
285
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
/** The old link stops working for everyone who has it. */
|
|
289
|
+
resetLink: async (reference) => {
|
|
290
|
+
const chatId = await this.chats.resolve(reference);
|
|
291
|
+
return this.#changeChat(chatId, "link.reset", async () => {
|
|
292
|
+
const answer = await this.#wire.chats.update({ chatId, revokePrivateLink: true });
|
|
293
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
294
|
+
});
|
|
295
|
+
},
|
|
118
296
|
};
|
|
119
297
|
contacts = {
|
|
120
298
|
/**
|
|
@@ -159,6 +337,39 @@ export class MaxClient {
|
|
|
159
337
|
* It is also the only thing that could ever prune somebody MAX has stopped returning, which is
|
|
160
338
|
* the second reason it exists.
|
|
161
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Finds whoever MAX has under a phone number. A lookup, not an add: nothing changes on the
|
|
342
|
+
* account. ⚠ The number is never repeated in an error — it is the one field here the sixth
|
|
343
|
+
* constraint is about.
|
|
344
|
+
*/
|
|
345
|
+
lookup: async (phone) => {
|
|
346
|
+
if (this.#offline)
|
|
347
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot look a number up");
|
|
348
|
+
const number = wirePhone(phone);
|
|
349
|
+
await this.#connectOnce();
|
|
350
|
+
const contact = record((await this.#wire.contacts.byPhone({ phone: number })).contact);
|
|
351
|
+
if (!contact)
|
|
352
|
+
throw new CliError("not_found", "MAX has nobody under that number");
|
|
353
|
+
return this.#remember(toContact(contact));
|
|
354
|
+
},
|
|
355
|
+
add: (reference) => this.#contactAction("contact-add", "ADD", reference),
|
|
356
|
+
remove: (reference) => this.#contactAction("contact-remove", "REMOVE", reference),
|
|
357
|
+
/** Uploads phone numbers to MAX — other people's — under the names they were saved with. */
|
|
358
|
+
import: (entries) => this.#change("contact-import", async () => {
|
|
359
|
+
if (entries.length === 0)
|
|
360
|
+
throw new CliError("validation_error", "no numbers to import");
|
|
361
|
+
const contactList = {};
|
|
362
|
+
for (const entry of entries)
|
|
363
|
+
contactList[wirePhone(entry.phone)] = { firstName: entry.name };
|
|
364
|
+
const answer = await this.#wire.contacts.import({ contactList });
|
|
365
|
+
return {
|
|
366
|
+
sent: Object.keys(contactList).length,
|
|
367
|
+
recognised: Object.keys(record(answer.phones) ?? {}),
|
|
368
|
+
contacts: asArray(answer.contacts)
|
|
369
|
+
.map(toContact)
|
|
370
|
+
.map((contact) => this.#remember(contact)),
|
|
371
|
+
};
|
|
372
|
+
}),
|
|
162
373
|
/**
|
|
163
374
|
* One person and the chats we share, **whoever they are** — a group member is as findable as
|
|
164
375
|
* a contact. `NEED-105` decides who `list` lists, not who can be looked up.
|
|
@@ -276,13 +487,13 @@ export class MaxClient {
|
|
|
276
487
|
* ⚠ **A message that is gone is refused, not replaced by its neighbour** — MAX answers with
|
|
277
488
|
* whatever is nearest, and showing that as the message asked for would be a quiet lie.
|
|
278
489
|
*/
|
|
279
|
-
around: async (chatId, messageId, { before = 0, after = 0 } = {}) => {
|
|
490
|
+
around: async (chatId, messageId, { before = 0, after = 0, reactions = true } = {}) => {
|
|
280
491
|
const time = timeOfMessageId(messageId);
|
|
281
492
|
if (time === undefined)
|
|
282
493
|
throw new CliError("validation_error", `"${messageId}" is not a message id`);
|
|
283
494
|
const found = this.#offline
|
|
284
495
|
? (this.#cache?.messages.window(chatId, time, before + 1, after) ?? [])
|
|
285
|
-
: await this.#history(chatId, { from: time, backward: before + 1, forward: after });
|
|
496
|
+
: await this.#history(chatId, { from: time, backward: before + 1, forward: after }, { reactions });
|
|
286
497
|
if (!found.some((message) => message.id === messageId)) {
|
|
287
498
|
throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
|
|
288
499
|
}
|
|
@@ -297,7 +508,7 @@ export class MaxClient {
|
|
|
297
508
|
links: async (chatId, messageId) => {
|
|
298
509
|
if (this.#offline)
|
|
299
510
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot download");
|
|
300
|
-
const [message] = await this.messages.around(chatId, messageId);
|
|
511
|
+
const [message] = await this.messages.around(chatId, messageId, { reactions: false });
|
|
301
512
|
if (!message)
|
|
302
513
|
throw new CliError("not_found", `no message ${messageId} in chat ${chatId}`);
|
|
303
514
|
const links = [];
|
|
@@ -360,36 +571,446 @@ export class MaxClient {
|
|
|
360
571
|
send: async (chatId, text, options = {}) => {
|
|
361
572
|
if (this.#offline)
|
|
362
573
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot send");
|
|
363
|
-
|
|
364
|
-
|
|
574
|
+
// Before connecting: a refused send never opens a socket when the chat was given as an id.
|
|
575
|
+
try {
|
|
576
|
+
this.#sends?.check(chatId);
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
this.#sends?.record({ chatId, outcome: "refused", errorCode: asCliError(error).code });
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
365
582
|
const cid = options.cid ?? this.#nextCid();
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
583
|
+
const files = await Promise.all((options.files ?? []).map(async (path) => ({ path, bytes: await readUpload(path), photo: isImage(path) })));
|
|
584
|
+
// Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
|
|
585
|
+
if (files.some((file) => !file.photo) && files.length > 1) {
|
|
586
|
+
throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
|
|
587
|
+
}
|
|
588
|
+
const attachments = files.map(({ bytes, photo }) => ({
|
|
589
|
+
kind: photo ? "photo" : "file",
|
|
590
|
+
bytes: bytes.length,
|
|
591
|
+
}));
|
|
592
|
+
const summary = attachments.length > 0 ? { attachments } : {};
|
|
372
593
|
try {
|
|
373
|
-
|
|
594
|
+
const sent = await this.#deliver(chatId, text, cid, { ...options, files });
|
|
595
|
+
this.#sends?.record({ chatId, outcome: "sent", messageId: sent.id, cid, length: text.length, ...summary });
|
|
596
|
+
return sent;
|
|
374
597
|
}
|
|
375
598
|
catch (error) {
|
|
376
599
|
const failure = asCliError(error);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
600
|
+
this.#sends?.record({
|
|
601
|
+
chatId,
|
|
602
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
603
|
+
cid,
|
|
604
|
+
length: text.length,
|
|
605
|
+
...summary,
|
|
606
|
+
errorCode: failure.code,
|
|
607
|
+
});
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
/** Puts one emoji reaction on a message; it replaces the one you had. */
|
|
612
|
+
react: (chatId, messageId, emoji) => this.#reaction(chatId, messageId, () => this.#wire.messages.react({ chatId, messageId, reaction: { reactionType: "EMOJI", id: emoji } })),
|
|
613
|
+
/** Takes your reaction off. Measured 2026-09-24: a second call is answered the same, not refused. */
|
|
614
|
+
unreact: (chatId, messageId) => this.#reaction(chatId, messageId, () => this.#wire.messages.unreact({ chatId, messageId })),
|
|
615
|
+
/**
|
|
616
|
+
* Changes the text of one of the owner's own messages. The person may have read it already.
|
|
617
|
+
*
|
|
618
|
+
* **Its attachments are sent back as history gives them**: measured 2026-09-24, an edit with
|
|
619
|
+
* none removes a photo from the message. Refused before asking MAX when the message is not the
|
|
620
|
+
* owner's, is a forward, or carries anything but photos — the web client offers none of those. MAX's own limit is
|
|
621
|
+
* `edit-timeout` from LOGIN, 604800 s when measured; past it MAX refuses and that is the answer.
|
|
622
|
+
* Not retried, like a reaction.
|
|
623
|
+
*/
|
|
624
|
+
edit: async (chatId, messageId, text, { markdown = false } = {}) => {
|
|
625
|
+
if (this.#offline)
|
|
626
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
|
|
627
|
+
this.#guard(chatId, "edit", messageId);
|
|
628
|
+
try {
|
|
629
|
+
await this.#connectOnce();
|
|
630
|
+
const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
|
|
631
|
+
const raw = await this.#rawMessage(chatId, messageId);
|
|
632
|
+
const current = toMessage(raw, chatId, lookup);
|
|
633
|
+
if (current.outgoing !== true) {
|
|
634
|
+
throw new CliError("validation_error", `message ${messageId} is not yours — only your own can be edited`);
|
|
383
635
|
}
|
|
384
|
-
|
|
385
|
-
throw new CliError("
|
|
386
|
-
`\`max messages send <chat> <text> --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
|
|
636
|
+
if (record(raw.link)?.type === "FORWARD") {
|
|
637
|
+
throw new CliError("validation_error", `message ${messageId} is a forward — a forward cannot be edited`);
|
|
387
638
|
}
|
|
639
|
+
// Only a photo was measured to survive the round trip; the web client refuses files, stickers and the rest too.
|
|
640
|
+
const other = current.attachments.find((attachment) => attachment.kind !== "photo");
|
|
641
|
+
if (other) {
|
|
642
|
+
throw new CliError("validation_error", `message ${messageId} carries a ${other.kind} — only text and photos can be edited`);
|
|
643
|
+
}
|
|
644
|
+
const { text: plain, markup } = markdown ? parseMarkdown(text) : { text, markup: [] };
|
|
645
|
+
const answer = await this.#wire.messages.edit({
|
|
646
|
+
chatId,
|
|
647
|
+
messageId,
|
|
648
|
+
text: plain,
|
|
649
|
+
elements: markup,
|
|
650
|
+
attachments: asArray(raw.attaches),
|
|
651
|
+
});
|
|
652
|
+
this.#cache?.messages.invalidate(chatId);
|
|
653
|
+
this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId, length: text.length });
|
|
654
|
+
return toMessage(record(answer.message) ?? raw, chatId, lookup);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
const failure = asCliError(error);
|
|
658
|
+
this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: failure.code });
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
/**
|
|
663
|
+
* Forwards one message into another chat: a new message there, so it goes through `#deliver`
|
|
664
|
+
* — the same `cid`, the same one retry — and counts against `sendsPerHour`.
|
|
665
|
+
*/
|
|
666
|
+
forward: async (fromChatId, messageId, toChatId, options = {}) => {
|
|
667
|
+
if (this.#offline)
|
|
668
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
|
|
669
|
+
this.#guard(toChatId, "forward");
|
|
670
|
+
const cid = options.cid ?? this.#nextCid();
|
|
671
|
+
try {
|
|
672
|
+
const sent = await this.#deliver(toChatId, "", cid, {
|
|
673
|
+
...options,
|
|
674
|
+
forward: { chatId: fromChatId, messageId },
|
|
675
|
+
repeat: `max messages forward ${fromChatId} ${messageId} --to ${toChatId}`,
|
|
676
|
+
});
|
|
677
|
+
this.#sends?.record({ chatId: toChatId, kind: "forward", outcome: "sent", messageId: sent.id, cid });
|
|
678
|
+
return sent;
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
const failure = asCliError(error);
|
|
682
|
+
this.#sends?.record({
|
|
683
|
+
chatId: toChatId,
|
|
684
|
+
kind: "forward",
|
|
685
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
686
|
+
cid,
|
|
687
|
+
errorCode: failure.code,
|
|
688
|
+
});
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
/**
|
|
693
|
+
* Pins one message in a chat, or with `null` unpins whatever is pinned — `pinMessageId: 0` is
|
|
694
|
+
* how the web client unpins. No notification unless asked (`NEED-196`). Not retried.
|
|
695
|
+
*
|
|
696
|
+
* **Never in a personal chat**: the web client's dialog class answers `viewerCanPin` with `false`, and
|
|
697
|
+
* MAX refused both in Saved messages and in a dialog with a person (measured 2026-09-24, `FIND-107`).
|
|
698
|
+
* A chat the login did not list is left to MAX. In a group, pin and unpin were measured the same day.
|
|
699
|
+
*/
|
|
700
|
+
pin: async (chatId, messageId, { notify = false } = {}) => {
|
|
701
|
+
if (this.#offline)
|
|
702
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
|
|
703
|
+
this.#guard(chatId, "pin", messageId ?? undefined);
|
|
704
|
+
try {
|
|
705
|
+
await this.#connectOnce();
|
|
706
|
+
const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
|
|
707
|
+
if (known && toChat(known).kind === "dialog") {
|
|
708
|
+
throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
|
|
709
|
+
}
|
|
710
|
+
await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
|
|
711
|
+
this.#sends?.record({ chatId, kind: "pin", outcome: "sent", ...(messageId ? { messageId } : {}) });
|
|
712
|
+
return { chatId, pinned: messageId };
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
this.#sends?.record({ chatId, kind: "pin", outcome: "failed", errorCode: asCliError(error).code });
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
folders = {
|
|
721
|
+
list: async () => (await this.#folders()).map(toFolder),
|
|
722
|
+
create: (title, chats = []) => this.#change("folder-create", async () => {
|
|
723
|
+
const include = await Promise.all(chats.map((chat) => this.chats.resolve(chat)));
|
|
724
|
+
const answer = await this.#wire.folders.update({
|
|
725
|
+
id: crypto.randomUUID(),
|
|
726
|
+
title: folderTitle(title),
|
|
727
|
+
include,
|
|
728
|
+
filters: [],
|
|
729
|
+
options: [],
|
|
730
|
+
});
|
|
731
|
+
return toFolder(record(answer.folder) ?? {});
|
|
732
|
+
}),
|
|
733
|
+
/**
|
|
734
|
+
* **The folder goes back whole**, as the web client sends it: an edit that left out the chats
|
|
735
|
+
* would empty the folder. What MAX keeps for itself — `sourceId`, `updateTime` — is not sent.
|
|
736
|
+
*/
|
|
737
|
+
update: (reference, change) => this.#change("folder-update", async () => {
|
|
738
|
+
const folder = pickFolder(reference, await this.#folders());
|
|
739
|
+
const added = await Promise.all((change.add ?? []).map((chat) => this.chats.resolve(chat)));
|
|
740
|
+
const removed = new Set(await Promise.all((change.remove ?? []).map((chat) => this.chats.resolve(chat))));
|
|
741
|
+
const touchesChats = added.length > 0 || removed.size > 0;
|
|
742
|
+
const current = toFolder(folder).chatIds;
|
|
743
|
+
const include = [...new Set([...current, ...added])].filter((id) => !removed.has(id));
|
|
744
|
+
const answer = await this.#wire.folders.update({
|
|
745
|
+
id: String(folder.id),
|
|
746
|
+
title: change.title === undefined ? String(folder.title ?? "") : folderTitle(change.title),
|
|
747
|
+
...(Array.isArray(folder.include) || touchesChats ? { include } : {}),
|
|
748
|
+
filters: Array.isArray(folder.filters) ? folder.filters : [],
|
|
749
|
+
options: Array.isArray(folder.options) ? folder.options : [],
|
|
750
|
+
...(Array.isArray(folder.favorites) ? { favorites: folder.favorites } : {}),
|
|
751
|
+
});
|
|
752
|
+
return toFolder(record(answer.folder) ?? {});
|
|
753
|
+
}),
|
|
754
|
+
/** The folder only — its chats stay where they are. */
|
|
755
|
+
delete: (reference) => this.#change("folder-delete", async () => {
|
|
756
|
+
const folder = pickFolder(reference, await this.#folders());
|
|
757
|
+
await this.#wire.folders.delete({ folderIds: [String(folder.id)] });
|
|
758
|
+
return toFolder(folder);
|
|
759
|
+
}),
|
|
760
|
+
};
|
|
761
|
+
inbox = {
|
|
762
|
+
/**
|
|
763
|
+
* Other people's unread messages, as MAX counts them: for each chat with a count, its newest
|
|
764
|
+
* that many. Reading changes nothing — no `CHAT_MARK` — so the same messages come back until
|
|
765
|
+
* they are read somewhere else. That is right for a person and wrong for a scheduled run,
|
|
766
|
+
* which is what `since` is for.
|
|
767
|
+
*/
|
|
768
|
+
unread: async ({ limit }) => {
|
|
769
|
+
const chats = (await this.chats.list()).items;
|
|
770
|
+
const waiting = byRecency(chats.filter((chat) => (chat.unreadCount ?? 0) > 0));
|
|
771
|
+
const { read, skipped } = capped(waiting);
|
|
772
|
+
const found = [];
|
|
773
|
+
for (const { id, title, kind, unreadCount } of read) {
|
|
774
|
+
const count = unreadCount ?? 0;
|
|
775
|
+
const wanted = Math.min(count, limit);
|
|
776
|
+
const { items } = await this.messages.list(id, { limit: wanted });
|
|
777
|
+
const theirs = items.slice(-wanted).filter((message) => message.outgoing !== true);
|
|
778
|
+
if (theirs.length > 0)
|
|
779
|
+
found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
|
|
780
|
+
}
|
|
781
|
+
return { mode: "unread", chats: found, skipped, partial: !this.#cache && chats.length >= LOGIN_CHATS };
|
|
782
|
+
},
|
|
783
|
+
/**
|
|
784
|
+
* Other people's messages in every chat that changed after `since`.
|
|
785
|
+
*
|
|
786
|
+
* A chat changed if its last message is later than `since`; the chat list says so without a
|
|
787
|
+
* request, and with a store it covers every chat, not only the ones this login named. Each
|
|
788
|
+
* changed chat then costs one history read of its newest `limit` — the newest, because the
|
|
789
|
+
* reader wants what just arrived, and one request rather than paging forward to reach it.
|
|
790
|
+
*
|
|
791
|
+
* **Everything is cut at the chat list's newest message**, the snapshot this login took. The
|
|
792
|
+
* reads run one after another, so a chat read early can gain a message while a later one is
|
|
793
|
+
* read; had the saved point followed the later read, that message would sit behind it and
|
|
794
|
+
* never show. Anything newer than the snapshot waits for the next run and shows once there.
|
|
795
|
+
*/
|
|
796
|
+
since: async ({ since, limit }) => {
|
|
797
|
+
const chats = (await this.chats.list()).items;
|
|
798
|
+
const changed = byRecency(chats.filter((chat) => chat.lastMessageAt !== null && Date.parse(chat.lastMessageAt) > since));
|
|
799
|
+
const cut = Math.max(since, ...changed.map((chat) => Date.parse(chat.lastMessageAt ?? "")));
|
|
800
|
+
const { read, skipped } = capped(changed);
|
|
801
|
+
let until = since;
|
|
802
|
+
const found = [];
|
|
803
|
+
for (const { id, title, kind, unreadCount } of read) {
|
|
804
|
+
const { items } = await this.messages.list(id, { limit });
|
|
805
|
+
const fresh = items.filter((message) => {
|
|
806
|
+
const time = Date.parse(message.timestamp);
|
|
807
|
+
return time > since && time <= cut;
|
|
808
|
+
});
|
|
809
|
+
for (const message of fresh)
|
|
810
|
+
until = Math.max(until, Date.parse(message.timestamp));
|
|
811
|
+
const theirs = fresh.filter((message) => message.outgoing !== true);
|
|
812
|
+
if (theirs.length > 0)
|
|
813
|
+
found.push({ id, title, kind, unreadCount, messages: theirs, more: fresh.length >= limit });
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
mode: "new",
|
|
817
|
+
since: new Date(since).toISOString(),
|
|
818
|
+
until: new Date(until).toISOString(),
|
|
819
|
+
chats: found,
|
|
820
|
+
skipped,
|
|
821
|
+
partial: !this.#cache && chats.length >= LOGIN_CHATS && changed.length === chats.length,
|
|
822
|
+
};
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
/**
|
|
826
|
+
* A reaction is seen by the other person, so it goes through the send guard and the send log like a
|
|
827
|
+
* message. Not retried: a reaction lost in transit costs a second command, and nothing about it is
|
|
828
|
+
* measured to make a blind repeat safe.
|
|
829
|
+
*/
|
|
830
|
+
async #reaction(chatId, messageId, call) {
|
|
831
|
+
if (this.#offline)
|
|
832
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
|
|
833
|
+
try {
|
|
834
|
+
this.#sends?.check(chatId, "reaction");
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
await this.#connectOnce();
|
|
842
|
+
const answer = await call();
|
|
843
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
|
|
844
|
+
return toReactions(record(answer.reactionInfo) ?? {});
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "failed", messageId, errorCode: asCliError(error).code });
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Asks the send guard, and writes a refusal to the send journal before passing it on. */
|
|
852
|
+
#guard(chatId, kind, messageId) {
|
|
853
|
+
try {
|
|
854
|
+
this.#sends?.check(chatId, kind);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
this.#sends?.record({
|
|
858
|
+
chatId,
|
|
859
|
+
kind,
|
|
860
|
+
outcome: "refused",
|
|
861
|
+
...(messageId ? { messageId } : {}),
|
|
862
|
+
errorCode: asCliError(error).code,
|
|
863
|
+
});
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/** One message as MAX sends it, for what the domain model drops — its attachments whole, its link. */
|
|
868
|
+
async #rawMessage(chatId, messageId) {
|
|
869
|
+
const time = timeOfMessageId(messageId);
|
|
870
|
+
if (time === undefined)
|
|
871
|
+
throw new CliError("validation_error", `"${messageId}" is not a message id`);
|
|
872
|
+
const answer = await this.#wire.chats.history({
|
|
873
|
+
chatId,
|
|
874
|
+
from: time,
|
|
875
|
+
forward: 0,
|
|
876
|
+
backward: 1,
|
|
877
|
+
forwardTime: 0,
|
|
878
|
+
backwardTime: 0,
|
|
879
|
+
itemType: "REGULAR",
|
|
880
|
+
getChat: false,
|
|
881
|
+
getMessages: true,
|
|
882
|
+
interactive: false,
|
|
883
|
+
});
|
|
884
|
+
const found = asArray(answer.messages)
|
|
885
|
+
.map((raw) => record(raw) ?? {})
|
|
886
|
+
.find((raw) => asId(raw.id) === messageId);
|
|
887
|
+
if (!found)
|
|
888
|
+
throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
|
|
889
|
+
return found;
|
|
890
|
+
}
|
|
891
|
+
async #deliver(chatId, text, cid, options) {
|
|
892
|
+
await this.#connectOnce();
|
|
893
|
+
const session = this.#session();
|
|
894
|
+
const attaches = [];
|
|
895
|
+
for (const file of options.files ?? [])
|
|
896
|
+
attaches.push(await this.#upload(file));
|
|
897
|
+
const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
|
|
898
|
+
// A forward carries no text or markup of its own — the web client leaves both out, and so was it measured.
|
|
899
|
+
const content = options.forward
|
|
900
|
+
? { link: { type: "FORWARD", ...options.forward } }
|
|
901
|
+
: {
|
|
902
|
+
text: plain,
|
|
903
|
+
elements: markup,
|
|
904
|
+
...(options.replyTo ? { link: { type: "REPLY", messageId: options.replyTo } } : {}),
|
|
905
|
+
};
|
|
906
|
+
const request = { chatId, message: { cid, attaches, ...content }, notify: options.notify ?? true };
|
|
907
|
+
let answer;
|
|
908
|
+
try {
|
|
909
|
+
answer = await this.#untilAttachmentsReady(() => this.#wire.messages.send(request));
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
const failure = asCliError(error);
|
|
913
|
+
if (failure.code !== "timeout" && failure.code !== "network_error")
|
|
914
|
+
throw failure;
|
|
915
|
+
// No answer came back, so MAX may already have delivered it. Repeating the identical `cid`
|
|
916
|
+
// is what makes asking again safe rather than reckless.
|
|
917
|
+
try {
|
|
918
|
+
answer = await this.#wire.messages.send(request);
|
|
388
919
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
920
|
+
catch {
|
|
921
|
+
throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
|
|
922
|
+
`\`${options.repeat ?? "max messages send <chat> <text>"} --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// What the cache holds for this chat is now one message short of the truth.
|
|
926
|
+
this.#cache?.messages.invalidate(chatId);
|
|
927
|
+
const sent = record(answer.message) ?? answer;
|
|
928
|
+
return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* **For a connection that stays open** (`max serve`). A one-shot command never calls these.
|
|
932
|
+
*/
|
|
933
|
+
live = {
|
|
934
|
+
/** The keep-alive the web client sends every 30 s; `interactive` is never true here. */
|
|
935
|
+
ping: async () => {
|
|
936
|
+
await this.#connectOnce();
|
|
937
|
+
await this.#wire.session.ping({ interactive: false });
|
|
938
|
+
},
|
|
939
|
+
/**
|
|
940
|
+
* The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
|
|
941
|
+
* a command as that command's own login.
|
|
942
|
+
*/
|
|
943
|
+
snapshot: () => this.#session(),
|
|
944
|
+
/**
|
|
945
|
+
* Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
|
|
946
|
+
* the server stops handing it out until it has logged in again. What web.max.ru does with each
|
|
947
|
+
* push (bundle read 2026-09-24) is what is copied here:
|
|
948
|
+
* - 128, a new message: the chat's last message and time move; unread goes up for somebody
|
|
949
|
+
* else's message and to zero for our own, as the app does once you have written in a chat.
|
|
950
|
+
* - 130, read up to a point: when we are the reader, the chat's unread becomes `unread`;
|
|
951
|
+
* somebody else reading changes nothing here.
|
|
952
|
+
* - 135, a chat changed: MAX sends the whole chat, and it replaces ours.
|
|
953
|
+
* Anything else that touches the chats — a deletion — cannot be followed.
|
|
954
|
+
*/
|
|
955
|
+
patch: (opcode, payload) => {
|
|
956
|
+
const chats = asArray(this.#session().chats);
|
|
957
|
+
const chatId = asId(payload.chatId);
|
|
958
|
+
const chat = chats.find((candidate) => asId(candidate.id) === chatId);
|
|
959
|
+
const viewerId = this.#store.readState().viewerId;
|
|
960
|
+
if (opcode === NEW_MESSAGE) {
|
|
961
|
+
const raw = record(payload.message);
|
|
962
|
+
if (!raw || !chat)
|
|
963
|
+
return false;
|
|
964
|
+
const ours = viewerId !== undefined && asId(raw.sender) === viewerId;
|
|
965
|
+
chat.lastMessage = raw;
|
|
966
|
+
chat.lastEventTime = raw.time;
|
|
967
|
+
chat.newMessages = ours ? 0 : (typeof chat.newMessages === "number" ? chat.newMessages : 0) + 1;
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
if (opcode === READ_MARK) {
|
|
971
|
+
if (asId(payload.userId) !== viewerId)
|
|
972
|
+
return true;
|
|
973
|
+
if (!chat || typeof payload.unread !== "number")
|
|
974
|
+
return false;
|
|
975
|
+
chat.newMessages = payload.unread;
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
if (opcode === CHAT_CHANGED) {
|
|
979
|
+
const changed = record(payload.chat);
|
|
980
|
+
const id = changed && asId(changed.id);
|
|
981
|
+
if (!changed || id === undefined)
|
|
982
|
+
return false;
|
|
983
|
+
const at = chats.findIndex((candidate) => asId(candidate.id) === id);
|
|
984
|
+
if (at >= 0)
|
|
985
|
+
chats[at] = changed;
|
|
986
|
+
else
|
|
987
|
+
chats.push(changed);
|
|
988
|
+
this.#session().chats = chats;
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
991
|
+
return !CHANGES_CHATS.has(opcode);
|
|
992
|
+
},
|
|
993
|
+
/** One request from a command, on this connection. The server decides which ones may pass. */
|
|
994
|
+
forward: async (opcode, payload) => {
|
|
995
|
+
await this.#connectOnce();
|
|
996
|
+
return this.#connection.invoke(opcode, payload);
|
|
997
|
+
},
|
|
998
|
+
/**
|
|
999
|
+
* A message MAX pushed (opcode 128) as the same shape `messages list` prints, with its chat's
|
|
1000
|
+
* name — so whoever reads `max watch` gets one schema, and no MAX type crosses this file.
|
|
1001
|
+
* Anything else pushed answers `undefined`.
|
|
1002
|
+
*/
|
|
1003
|
+
message: async (opcode, payload) => {
|
|
1004
|
+
const raw = record(payload.message);
|
|
1005
|
+
const chatId = asId(payload.chatId);
|
|
1006
|
+
if (opcode !== NEW_MESSAGE || !raw || chatId === undefined)
|
|
1007
|
+
return undefined;
|
|
1008
|
+
const session = this.#session();
|
|
1009
|
+
const [message] = await this.#nameSenders([
|
|
1010
|
+
toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
|
|
1011
|
+
]);
|
|
1012
|
+
const chat = (await this.chats.list()).items.find((candidate) => candidate.id === chatId);
|
|
1013
|
+
return message && { ...message, chatTitle: chat?.title ?? null };
|
|
393
1014
|
},
|
|
394
1015
|
};
|
|
395
1016
|
/**
|
|
@@ -409,7 +1030,7 @@ export class MaxClient {
|
|
|
409
1030
|
throw new CliError("authentication_error", `no session for profile "${this.#store.profile}" — run \`max ${asFirstWord(this.#store.profile)}session start\``);
|
|
410
1031
|
}
|
|
411
1032
|
const state = this.#store.readState();
|
|
412
|
-
const sync = this.#cache?.syncMarker();
|
|
1033
|
+
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
413
1034
|
try {
|
|
414
1035
|
await this.#connection.open();
|
|
415
1036
|
this.#login = await startSession(this.#invoke, {
|
|
@@ -551,7 +1172,7 @@ export class MaxClient {
|
|
|
551
1172
|
* public for the one command that must reach MAX to mean anything — starting a session.
|
|
552
1173
|
*/
|
|
553
1174
|
/** `interactive: false` and never `CHAT_MARK`: reading history must not mark anything read (§19). */
|
|
554
|
-
async #history(chatId, window) {
|
|
1175
|
+
async #history(chatId, window, { reactions = true } = {}) {
|
|
555
1176
|
await this.#connectOnce();
|
|
556
1177
|
const session = this.#session();
|
|
557
1178
|
const answer = await this.#wire.chats.history({
|
|
@@ -567,7 +1188,24 @@ export class MaxClient {
|
|
|
567
1188
|
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
568
1189
|
const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
|
|
569
1190
|
this.#cache?.messages.write(chatId, messages);
|
|
570
|
-
return messages;
|
|
1191
|
+
return reactions ? this.#withReactions(chatId, messages) : messages;
|
|
1192
|
+
}
|
|
1193
|
+
/** One request per page: history carries no reactions (measured 2026-09-23). */
|
|
1194
|
+
async #withReactions(chatId, messages) {
|
|
1195
|
+
if (messages.length === 0)
|
|
1196
|
+
return messages;
|
|
1197
|
+
try {
|
|
1198
|
+
const answer = await this.#wire.messages.reactions({ chatId, messageIds: messages.map((message) => message.id) });
|
|
1199
|
+
const byId = record(answer.messagesReactions) ?? {};
|
|
1200
|
+
return messages.map((message) => {
|
|
1201
|
+
const raw = record(byId[message.id]);
|
|
1202
|
+
return { ...message, reactions: raw ? toReactions(raw) : { counts: [], mine: null, total: 0 } };
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
catch (error) {
|
|
1206
|
+
this.#warn(`reactions are not shown: they could not be read (${reasonOf(error)})`);
|
|
1207
|
+
return messages;
|
|
1208
|
+
}
|
|
571
1209
|
}
|
|
572
1210
|
/**
|
|
573
1211
|
* **A group member who is not a contact arrives as a bare id** — the login names contacts only,
|
|
@@ -610,6 +1248,17 @@ export class MaxClient {
|
|
|
610
1248
|
forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
|
|
611
1249
|
}));
|
|
612
1250
|
}
|
|
1251
|
+
/** INIT with the profile's own device, which is the device MAX then issues the token to (bite 8). */
|
|
1252
|
+
async #beforeLogin(flow) {
|
|
1253
|
+
try {
|
|
1254
|
+
await this.#connection.open();
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
throw asCliError(error);
|
|
1258
|
+
}
|
|
1259
|
+
await this.#wire.session.init({ userAgent: WEB_USER_AGENT, deviceId: this.#store.readState().deviceId });
|
|
1260
|
+
return await flow();
|
|
1261
|
+
}
|
|
613
1262
|
async #connectOnce() {
|
|
614
1263
|
if (!this.#login)
|
|
615
1264
|
await this.connect();
|
|
@@ -686,6 +1335,105 @@ export class MaxClient {
|
|
|
686
1335
|
* by a test, not by a lost message. Across processes this is still millisecond-grained, which is
|
|
687
1336
|
* safe while one invocation sends one message.
|
|
688
1337
|
*/
|
|
1338
|
+
/**
|
|
1339
|
+
* Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
|
|
1340
|
+
* retried: a failure here happens before `MSG_SEND`, so nothing was sent.
|
|
1341
|
+
*/
|
|
1342
|
+
async #upload({ path, bytes, photo }) {
|
|
1343
|
+
const request = { count: 1, type: 0, uploaderType: 0, profile: false };
|
|
1344
|
+
if (photo) {
|
|
1345
|
+
const { url } = await this.#wire.uploads.photo(request);
|
|
1346
|
+
if (typeof url !== "string")
|
|
1347
|
+
throw new CliError("provider_error", "MAX gave no address to upload the photo to");
|
|
1348
|
+
return { _type: "PHOTO", photoToken: await uploadPhoto(url, path, bytes) };
|
|
1349
|
+
}
|
|
1350
|
+
const info = record(asArray((await this.#wire.uploads.file(request)).info)[0]) ?? {};
|
|
1351
|
+
if (typeof info.url !== "string" || info.fileId === undefined) {
|
|
1352
|
+
throw new CliError("provider_error", "MAX gave no address to upload the file to");
|
|
1353
|
+
}
|
|
1354
|
+
await uploadFile(info.url, path, bytes);
|
|
1355
|
+
return { _type: "FILE", fileId: info.fileId };
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* **A file is processed after its upload**, and a send before that is refused
|
|
1359
|
+
* `attachment.not.ready` (measured 2026-09-24). A refusal means nothing was sent, so asking again
|
|
1360
|
+
* with the same request — the same `cid` — is safe. Once a second, for up to thirty.
|
|
1361
|
+
*/
|
|
1362
|
+
async #untilAttachmentsReady(send) {
|
|
1363
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1364
|
+
try {
|
|
1365
|
+
return await send();
|
|
1366
|
+
}
|
|
1367
|
+
catch (error) {
|
|
1368
|
+
if (attempt >= 30 || !asCliError(error).message.includes("attachment.not.ready"))
|
|
1369
|
+
throw error;
|
|
1370
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
|
|
1376
|
+
* measured to be safe to repeat, and a repeated `create` is a second group.
|
|
1377
|
+
*/
|
|
1378
|
+
async #changeChat(chatId, action, act) {
|
|
1379
|
+
if (this.#offline)
|
|
1380
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
|
|
1381
|
+
try {
|
|
1382
|
+
this.#sends?.check(chatId, "chat", action);
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
|
|
1386
|
+
throw error;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
await this.#connectOnce();
|
|
1390
|
+
const done = await act();
|
|
1391
|
+
this.#sends?.record({
|
|
1392
|
+
chatId: done.chatId,
|
|
1393
|
+
kind: "chat",
|
|
1394
|
+
action,
|
|
1395
|
+
outcome: "sent",
|
|
1396
|
+
...(done.people === undefined ? {} : { people: done.people }),
|
|
1397
|
+
});
|
|
1398
|
+
return done.result;
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
const failure = asCliError(error);
|
|
1402
|
+
this.#sends?.record({
|
|
1403
|
+
chatId,
|
|
1404
|
+
kind: "chat",
|
|
1405
|
+
action,
|
|
1406
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
1407
|
+
errorCode: failure.code,
|
|
1408
|
+
});
|
|
1409
|
+
throw error;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
async #updateMembers(reference, people, action, change) {
|
|
1413
|
+
const chatId = await this.chats.resolve(reference);
|
|
1414
|
+
return this.#changeChat(chatId, action, async () => {
|
|
1415
|
+
const userIds = await this.#personIds(people);
|
|
1416
|
+
const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
|
|
1417
|
+
const chat = record(answer.chat);
|
|
1418
|
+
return {
|
|
1419
|
+
chatId,
|
|
1420
|
+
people: userIds.length,
|
|
1421
|
+
result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
|
|
1422
|
+
};
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
/** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
|
|
1426
|
+
async #personIds(references) {
|
|
1427
|
+
if (references.every(isId))
|
|
1428
|
+
return references.map((reference) => reference.trim());
|
|
1429
|
+
const cache = this.#cache;
|
|
1430
|
+
if (!cache) {
|
|
1431
|
+
throw new CliError("configuration_error", `there is no local store for profile "${this.#store.profile}" to look names up in — give people by id`);
|
|
1432
|
+
}
|
|
1433
|
+
await this.#connectOnce();
|
|
1434
|
+
await this.#peopleFor(asArray(this.#session().chats));
|
|
1435
|
+
return references.map((reference) => (isId(reference) ? reference.trim() : pickPerson(reference, cache).id));
|
|
1436
|
+
}
|
|
689
1437
|
#nextCid() {
|
|
690
1438
|
const now = Date.now();
|
|
691
1439
|
this.#previousCid = now > this.#previousCid ? now : this.#previousCid + 1;
|
|
@@ -750,12 +1498,117 @@ export class MaxClient {
|
|
|
750
1498
|
const others = Object.keys(participants).filter((id) => id !== viewerId);
|
|
751
1499
|
return others.length === 1 ? others[0] : undefined;
|
|
752
1500
|
}
|
|
1501
|
+
/**
|
|
1502
|
+
* A change to the account itself, not to a chat: a read-only profile refuses it, no recipient
|
|
1503
|
+
* list applies, it does not count towards the hourly limit, and the journal records which kind
|
|
1504
|
+
* of change it was. **Never retried** — at worst the command is typed again.
|
|
1505
|
+
*/
|
|
1506
|
+
async #change(action, body) {
|
|
1507
|
+
if (this.#offline)
|
|
1508
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
|
|
1509
|
+
try {
|
|
1510
|
+
this.#sends?.check(null, "account");
|
|
1511
|
+
}
|
|
1512
|
+
catch (error) {
|
|
1513
|
+
this.#sends?.record({
|
|
1514
|
+
chatId: null,
|
|
1515
|
+
kind: "account",
|
|
1516
|
+
action,
|
|
1517
|
+
outcome: "refused",
|
|
1518
|
+
errorCode: asCliError(error).code,
|
|
1519
|
+
});
|
|
1520
|
+
throw error;
|
|
1521
|
+
}
|
|
1522
|
+
let recorded = false;
|
|
1523
|
+
const done = () => {
|
|
1524
|
+
if (recorded)
|
|
1525
|
+
return;
|
|
1526
|
+
recorded = true;
|
|
1527
|
+
this.#sends?.record({ chatId: null, kind: "account", action, outcome: "sent" });
|
|
1528
|
+
};
|
|
1529
|
+
try {
|
|
1530
|
+
await this.#connectOnce();
|
|
1531
|
+
const result = await body(done);
|
|
1532
|
+
done();
|
|
1533
|
+
return result;
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
if (!recorded) {
|
|
1537
|
+
this.#sends?.record({
|
|
1538
|
+
chatId: null,
|
|
1539
|
+
kind: "account",
|
|
1540
|
+
action,
|
|
1541
|
+
outcome: "failed",
|
|
1542
|
+
errorCode: asCliError(error).code,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
throw error;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
#contactAction(action, wire, reference) {
|
|
1549
|
+
return this.#change(action, async () => {
|
|
1550
|
+
const cache = this.#cache;
|
|
1551
|
+
const known = isId(reference) ? cache?.people.get(reference.trim()) : undefined;
|
|
1552
|
+
if (!isId(reference) && !cache) {
|
|
1553
|
+
throw new CliError("validation_error", "without a local store a person is named by id — `max contacts lookup` finds one");
|
|
1554
|
+
}
|
|
1555
|
+
const id = isId(reference) ? reference.trim() : pickPerson(reference, cache).id;
|
|
1556
|
+
const answer = await this.#wire.contacts.update({ contactId: id, action: wire });
|
|
1557
|
+
const contact = record(answer.contact);
|
|
1558
|
+
if (contact)
|
|
1559
|
+
return this.#remember(toContact(contact));
|
|
1560
|
+
return known ?? { id, name: null, username: null, description: null, lastMessagedAt: null };
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
#remember(contact) {
|
|
1564
|
+
if (contact.id !== "")
|
|
1565
|
+
this.#cache?.people.upsert([contact], "info");
|
|
1566
|
+
return contact;
|
|
1567
|
+
}
|
|
1568
|
+
async #folders() {
|
|
1569
|
+
if (this.#offline)
|
|
1570
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded, and folders are not recorded");
|
|
1571
|
+
await this.#connectOnce();
|
|
1572
|
+
const answer = await this.#wire.folders.list({ folderSync: 0 });
|
|
1573
|
+
const folders = asArray(answer.folders);
|
|
1574
|
+
const order = Array.isArray(answer.foldersOrder) ? answer.foldersOrder.map(String) : [];
|
|
1575
|
+
const rank = (folder) => {
|
|
1576
|
+
const at = order.indexOf(String(folder.id));
|
|
1577
|
+
return at === -1 ? order.length : at;
|
|
1578
|
+
};
|
|
1579
|
+
return [...folders].sort((a, b) => rank(a) - rank(b));
|
|
1580
|
+
}
|
|
753
1581
|
#session() {
|
|
754
1582
|
if (!this.#login)
|
|
755
1583
|
throw new CliError("configuration_error", "connect() was never called");
|
|
756
1584
|
return this.#login;
|
|
757
1585
|
}
|
|
758
1586
|
}
|
|
1587
|
+
/** PyMax `AdminPermission`; the sum is what MAX takes. */
|
|
1588
|
+
export const ADMIN_RIGHTS = {
|
|
1589
|
+
members: 2,
|
|
1590
|
+
admins: 4,
|
|
1591
|
+
info: 8,
|
|
1592
|
+
pin: 16,
|
|
1593
|
+
post: 256,
|
|
1594
|
+
edit: 512,
|
|
1595
|
+
delete: 1024,
|
|
1596
|
+
};
|
|
1597
|
+
/** PyMax asks for this many; paging join requests is not known. */
|
|
1598
|
+
const JOIN_REQUESTS = 100;
|
|
1599
|
+
/**
|
|
1600
|
+
* A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
|
|
1601
|
+
* measured. A public one (`https://max.ru/<name>`) goes whole, which is PyMax's claim only.
|
|
1602
|
+
*/
|
|
1603
|
+
export const wireLink = (link) => {
|
|
1604
|
+
const trimmed = link.trim();
|
|
1605
|
+
const at = trimmed.indexOf("join/");
|
|
1606
|
+
if (at >= 0 && trimmed.length > at + "join/".length)
|
|
1607
|
+
return trimmed.slice(at);
|
|
1608
|
+
if (/^(https:\/\/)?max\.ru\/[\w.-]+\/?$/.test(trimmed))
|
|
1609
|
+
return trimmed;
|
|
1610
|
+
throw new CliError("validation_error", "not a MAX link — expected https://max.ru/join/… or https://max.ru/<name>");
|
|
1611
|
+
};
|
|
759
1612
|
const largestMp4 = (answer) => Object.entries(answer)
|
|
760
1613
|
.map(([key, value]) => ({ height: /^MP4_(\d+)$/.exec(key)?.[1], value }))
|
|
761
1614
|
.filter((entry) => entry.height !== undefined && typeof entry.value === "string")
|
|
@@ -770,51 +1623,6 @@ const largestMp4 = (answer) => Object.entries(answer)
|
|
|
770
1623
|
* on something the person never asked about.
|
|
771
1624
|
*/
|
|
772
1625
|
const MIN_QUERY = 3;
|
|
773
|
-
const isId = (reference) => /^-?\d+$/.test(reference.trim());
|
|
774
|
-
/**
|
|
775
|
-
* A name matched exactly first, then as a fragment — and **an ambiguous one is an error, not a
|
|
776
|
-
* guess**: sending to the wrong conversation is not undoable, so the caller is shown the
|
|
777
|
-
* candidates and asked to be specific.
|
|
778
|
-
*/
|
|
779
|
-
const pickChat = (reference, chats) => {
|
|
780
|
-
const wanted = reference.trim().toLowerCase();
|
|
781
|
-
const titled = chats.filter((chat) => chat.title !== null);
|
|
782
|
-
const exact = titled.filter((chat) => chat.title?.toLowerCase() === wanted);
|
|
783
|
-
const matches = exact.length > 0 ? exact : titled.filter((chat) => chat.title?.toLowerCase().includes(wanted));
|
|
784
|
-
if (matches.length === 1 && matches[0])
|
|
785
|
-
return matches[0];
|
|
786
|
-
if (matches.length === 0)
|
|
787
|
-
throw new CliError("not_found", `no chat matches "${reference}"`);
|
|
788
|
-
throw ambiguous(reference, "chats", matches.map((chat) => ({ id: chat.id, title: chat.title })), ({ title }) => String(title));
|
|
789
|
-
};
|
|
790
|
-
/**
|
|
791
|
-
* As `pickChat`, over names and @usernames. Matched here rather than in SQL because SQLite's
|
|
792
|
-
* `lower()` folds ASCII only, and most of these names are Cyrillic.
|
|
793
|
-
*/
|
|
794
|
-
const pickPerson = (reference, cache) => {
|
|
795
|
-
const trimmed = reference.trim();
|
|
796
|
-
if (isId(trimmed)) {
|
|
797
|
-
const known = cache.people.get(trimmed);
|
|
798
|
-
if (!known)
|
|
799
|
-
throw new CliError("not_found", `no person ${trimmed} in what this account has seen`);
|
|
800
|
-
return known;
|
|
801
|
-
}
|
|
802
|
-
const wanted = trimmed.replace(/^@/, "").toLowerCase();
|
|
803
|
-
const everyone = cache.people.page({ order: "name", limit: Number.MAX_SAFE_INTEGER, offset: 0 });
|
|
804
|
-
const fields = (person) => [person.name, person.username].filter(isPresent).map((one) => one.toLowerCase());
|
|
805
|
-
const exact = everyone.filter((person) => fields(person).includes(wanted));
|
|
806
|
-
const matches = exact.length > 0 ? exact : everyone.filter((person) => fields(person).some((one) => one.includes(wanted)));
|
|
807
|
-
if (matches.length === 1 && matches[0])
|
|
808
|
-
return matches[0];
|
|
809
|
-
if (matches.length === 0)
|
|
810
|
-
throw new CliError("not_found", `nobody matches "${reference}"`);
|
|
811
|
-
throw ambiguous(reference, "people", matches.map(({ id, name, username }) => ({ id, name, username })), ({ name, username }) => [name, username && `@${username}`].filter(isPresent).join(" "));
|
|
812
|
-
};
|
|
813
|
-
const ambiguous = (reference, what, candidates, label) => {
|
|
814
|
-
const width = Math.max(...candidates.map(({ id }) => id.length));
|
|
815
|
-
const lines = candidates.map((candidate) => ` ${candidate.id.padEnd(width)} ${label(candidate)}`).join("\n");
|
|
816
|
-
return new CliError("validation_error", `"${reference}" matches ${candidates.length} ${what} — name one by its id:\n${lines}`, { candidates });
|
|
817
|
-
};
|
|
818
1626
|
const checkedQuery = (query) => {
|
|
819
1627
|
if (query === undefined)
|
|
820
1628
|
return undefined;
|
|
@@ -831,6 +1639,37 @@ const checkedQuery = (query) => {
|
|
|
831
1639
|
* names itself rather than as a wrong answer.
|
|
832
1640
|
*/
|
|
833
1641
|
const CONTACT_INFO_BATCH = 100;
|
|
1642
|
+
/**
|
|
1643
|
+
* When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
|
|
1644
|
+
* low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
|
|
1645
|
+
* `undefined` for anything that is not a message id.
|
|
1646
|
+
*/
|
|
1647
|
+
export const timeOfMessageId = (id) => {
|
|
1648
|
+
if (!/^\d{10,20}$/.test(id))
|
|
1649
|
+
return undefined;
|
|
1650
|
+
const time = Number(BigInt(id) >> 16n);
|
|
1651
|
+
return Number.isSafeInteger(time) && time > 0 ? time : undefined;
|
|
1652
|
+
};
|
|
1653
|
+
/**
|
|
1654
|
+
* **At most this many history reads per `max inbox`.** The official client reads a chat's history
|
|
1655
|
+
* when a person opens it; twenty in one burst is already more than a person does (§34). A personal
|
|
1656
|
+
* account rarely has that many chats change between two checks, and the rest are named, not lost.
|
|
1657
|
+
*/
|
|
1658
|
+
const INBOX_CHATS = 20;
|
|
1659
|
+
/** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
|
|
1660
|
+
const NEW_MESSAGE = 128;
|
|
1661
|
+
/** Read up to a point — by us on another device, or by somebody else. */
|
|
1662
|
+
const READ_MARK = 130;
|
|
1663
|
+
/** A chat changed; MAX sends it whole. */
|
|
1664
|
+
const CHAT_CHANGED = 135;
|
|
1665
|
+
/** Messages deleted (140 in PyMax, 142 in the web client): the snapshot cannot follow them. */
|
|
1666
|
+
const CHANGES_CHATS = new Set([140, 142]);
|
|
1667
|
+
/** Newest first — the chats a reader most likely came for are read before the cap. */
|
|
1668
|
+
const byRecency = (chats) => chats.toSorted((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? ""));
|
|
1669
|
+
const capped = (chats) => ({
|
|
1670
|
+
read: chats.slice(0, INBOX_CHATS),
|
|
1671
|
+
skipped: chats.slice(INBOX_CHATS).map(({ id, title, lastMessageAt }) => ({ id, title, lastMessageAt })),
|
|
1672
|
+
});
|
|
834
1673
|
const isPresent = (value) => value !== null && value !== undefined;
|
|
835
1674
|
const needsName = (message) => message.senderId !== null && message.senderName === null && message.outgoing !== true;
|
|
836
1675
|
const batched = (items, size) => {
|
|
@@ -911,4 +1750,46 @@ const asCliError = (error) => {
|
|
|
911
1750
|
return new CliError("timeout", message);
|
|
912
1751
|
return new CliError("network_error", message);
|
|
913
1752
|
};
|
|
1753
|
+
const ownNames = (profile) => {
|
|
1754
|
+
const contact = record(profile.contact) ?? profile;
|
|
1755
|
+
const names = asArray(contact.names);
|
|
1756
|
+
const own = names.find((entry) => entry.type === "ONEME") ?? names[0];
|
|
1757
|
+
return {
|
|
1758
|
+
...(typeof own?.firstName === "string" && own.firstName !== "" ? { firstName: own.firstName } : {}),
|
|
1759
|
+
...(typeof own?.lastName === "string" ? { lastName: own.lastName } : {}),
|
|
1760
|
+
};
|
|
1761
|
+
};
|
|
1762
|
+
/** `+` and digits, as the lookup was measured. ⚠ The refusal never repeats what was typed. */
|
|
1763
|
+
export const wirePhone = (typed) => {
|
|
1764
|
+
const compact = typed.replace(/[\s()-]/g, "");
|
|
1765
|
+
const digits = compact.replace(/^\+/, "");
|
|
1766
|
+
if (!/^\d{7,15}$/.test(digits)) {
|
|
1767
|
+
throw new CliError("validation_error", "a phone number is 7 to 15 digits, with an optional + and the country code");
|
|
1768
|
+
}
|
|
1769
|
+
// A Russian number written the domestic way would otherwise go out as +8…, somebody else's.
|
|
1770
|
+
if (!compact.startsWith("+") && /^8\d{10}$/.test(digits)) {
|
|
1771
|
+
throw new CliError("validation_error", "write a number that starts with 8 with its country code instead: +7…");
|
|
1772
|
+
}
|
|
1773
|
+
return `+${digits}`;
|
|
1774
|
+
};
|
|
1775
|
+
/** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
|
|
1776
|
+
const folderTitle = (title) => {
|
|
1777
|
+
const trimmed = title.trim();
|
|
1778
|
+
if (trimmed === "")
|
|
1779
|
+
throw new CliError("validation_error", "a folder needs a title");
|
|
1780
|
+
return trimmed;
|
|
1781
|
+
};
|
|
1782
|
+
const pickFolder = (reference, folders) => {
|
|
1783
|
+
const wanted = reference.trim();
|
|
1784
|
+
const byId = folders.find((folder) => folder.id === wanted);
|
|
1785
|
+
if (byId)
|
|
1786
|
+
return byId;
|
|
1787
|
+
const named = folders.filter((folder) => typeof folder.title === "string" && folder.title === wanted);
|
|
1788
|
+
if (named.length === 1 && named[0])
|
|
1789
|
+
return named[0];
|
|
1790
|
+
if (named.length > 1) {
|
|
1791
|
+
throw new CliError("validation_error", `${named.length} folders are called "${wanted}" — name one by id (\`max chats folders list\`)`);
|
|
1792
|
+
}
|
|
1793
|
+
throw new CliError("not_found", `no folder "${wanted}" — \`max chats folders list\` shows them`);
|
|
1794
|
+
};
|
|
914
1795
|
//# sourceMappingURL=client.js.map
|