@leemour/max-cli 0.6.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 +8 -0
- 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/client.d.ts +178 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +764 -34
- 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/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 +4 -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 +8 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +26 -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.js +2 -2
- package/dist/commands/inbox.js.map +1 -1
- 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 +84 -2
- package/dist/commands/messages.js.map +1 -1
- package/dist/commands/reactions.d.ts.map +1 -1
- package/dist/commands/reactions.js +17 -0
- package/dist/commands/reactions.js.map +1 -1
- 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 +4 -1
- package/dist/commands/session.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 +16 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +27 -4
- package/dist/config.js.map +1 -1
- package/dist/domain/map.d.ts +8 -1
- package/dist/domain/map.d.ts.map +1 -1
- package/dist/domain/map.js +42 -1
- package/dist/domain/map.js.map +1 -1
- package/dist/domain/models.d.ts +47 -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 +26 -0
- package/dist/generated/client.generated.d.ts.map +1 -1
- package/dist/generated/client.generated.js +26 -0
- package/dist/generated/client.generated.js.map +1 -1
- package/dist/generated/opcodes.generated.d.ts +24 -5
- package/dist/generated/opcodes.generated.d.ts.map +1 -1
- package/dist/generated/opcodes.generated.js +24 -5
- package/dist/generated/opcodes.generated.js.map +1 -1
- package/dist/generated/operations.generated.d.ts +163 -6
- package/dist/generated/operations.generated.d.ts.map +1 -1
- package/dist/generated/operations.generated.js +27 -4
- package/dist/generated/operations.generated.js.map +1 -1
- 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 +17 -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 +17 -9
- package/dist/rendering/messages.js.map +1 -1
- package/dist/sends/guard.d.ts +3 -2
- package/dist/sends/guard.d.ts.map +1 -1
- package/dist/sends/guard.js +14 -10
- package/dist/sends/guard.js.map +1 -1
- package/dist/sends/journal.d.ts +15 -2
- package/dist/sends/journal.d.ts.map +1 -1
- package/dist/sends/journal.js.map +1 -1
- 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/browser.d.ts +1 -1
- package/dist/session/browser.d.ts.map +1 -1
- package/dist/session/browser.js +86 -59
- package/dist/session/browser.js.map +1 -1
- package/dist/session/store.d.ts +2 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +4 -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 +28 -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/messages.d.ts +44 -6
- package/dist/spec/operations/messages.d.ts.map +1 -1
- package/dist/spec/operations/messages.js +86 -17
- 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 +12 -2
- package/dist/testing/mock-max.d.ts.map +1 -1
- package/dist/testing/mock-max.js +15 -2
- package/dist/testing/mock-max.js.map +1 -1
- package/dist/testing/sandbox.js +2 -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 +12 -3
- package/skills/max-cli/SKILL.md +10 -1
package/dist/client.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { CliError } from "@leemour/cli-core";
|
|
2
|
-
import { namesFrom, toChat, toContact, toMessage, toProfile, toReactions } from "./domain/map.js";
|
|
2
|
+
import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
|
|
3
3
|
import { wireClient } from "./generated/client.generated.js";
|
|
4
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";
|
|
7
8
|
import { isId, pickChat, pickPerson } from "./resolve.js";
|
|
8
9
|
import { countsIn, idsOf } from "./runs/events.js";
|
|
9
10
|
import { LOGIN_CHATS, startSession } from "./session/handshake.js";
|
|
10
11
|
import { tokenByQr } from "./session/login.js";
|
|
11
12
|
import { WEB_USER_AGENT } from "./spec/identity.js";
|
|
12
13
|
import { buildRequest, checkResponse } from "./spec/index.js";
|
|
14
|
+
import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
|
|
13
15
|
/**
|
|
14
16
|
* **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
|
|
15
17
|
* opcodes and the frames stop here, so a different transport underneath changes this file and
|
|
@@ -25,6 +27,7 @@ import { buildRequest, checkResponse } from "./spec/index.js";
|
|
|
25
27
|
export class MaxClient {
|
|
26
28
|
#store;
|
|
27
29
|
#connection;
|
|
30
|
+
#fullLogin;
|
|
28
31
|
#warn;
|
|
29
32
|
#cache;
|
|
30
33
|
#offline;
|
|
@@ -36,8 +39,9 @@ export class MaxClient {
|
|
|
36
39
|
#previousCid = 0;
|
|
37
40
|
#people;
|
|
38
41
|
#merged;
|
|
39
|
-
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends }) {
|
|
42
|
+
constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends, fullLogin = false, }) {
|
|
40
43
|
this.#store = store;
|
|
44
|
+
this.#fullLogin = fullLogin;
|
|
41
45
|
this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
|
|
42
46
|
this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
|
|
43
47
|
this.#cache = cache;
|
|
@@ -46,7 +50,61 @@ export class MaxClient {
|
|
|
46
50
|
this.#sends = sends;
|
|
47
51
|
}
|
|
48
52
|
account = {
|
|
49
|
-
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
|
+
}),
|
|
50
108
|
};
|
|
51
109
|
/**
|
|
52
110
|
* Obtaining a token rather than using one. The token comes back to the caller, unstored and not yet
|
|
@@ -128,6 +186,113 @@ export class MaxClient {
|
|
|
128
186
|
const cache = this.#cache;
|
|
129
187
|
return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
|
|
130
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
|
+
},
|
|
131
296
|
};
|
|
132
297
|
contacts = {
|
|
133
298
|
/**
|
|
@@ -172,6 +337,39 @@ export class MaxClient {
|
|
|
172
337
|
* It is also the only thing that could ever prune somebody MAX has stopped returning, which is
|
|
173
338
|
* the second reason it exists.
|
|
174
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
|
+
}),
|
|
175
373
|
/**
|
|
176
374
|
* One person and the chats we share, **whoever they are** — a group member is as findable as
|
|
177
375
|
* a contact. `NEED-105` decides who `list` lists, not who can be looked up.
|
|
@@ -382,9 +580,19 @@ export class MaxClient {
|
|
|
382
580
|
throw error;
|
|
383
581
|
}
|
|
384
582
|
const cid = options.cid ?? this.#nextCid();
|
|
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 } : {};
|
|
385
593
|
try {
|
|
386
|
-
const sent = await this.#deliver(chatId, text, cid, options);
|
|
387
|
-
this.#sends?.record({ chatId, outcome: "sent", messageId: sent.id, cid, length: text.length });
|
|
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 });
|
|
388
596
|
return sent;
|
|
389
597
|
}
|
|
390
598
|
catch (error) {
|
|
@@ -394,46 +602,161 @@ export class MaxClient {
|
|
|
394
602
|
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
395
603
|
cid,
|
|
396
604
|
length: text.length,
|
|
605
|
+
...summary,
|
|
397
606
|
errorCode: failure.code,
|
|
398
607
|
});
|
|
399
608
|
throw error;
|
|
400
609
|
}
|
|
401
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 })),
|
|
402
615
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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.
|
|
405
623
|
*/
|
|
406
|
-
|
|
624
|
+
edit: async (chatId, messageId, text, { markdown = false } = {}) => {
|
|
407
625
|
if (this.#offline)
|
|
408
|
-
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot
|
|
626
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
|
|
627
|
+
this.#guard(chatId, "edit", messageId);
|
|
409
628
|
try {
|
|
410
|
-
this.#
|
|
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`);
|
|
635
|
+
}
|
|
636
|
+
if (record(raw.link)?.type === "FORWARD") {
|
|
637
|
+
throw new CliError("validation_error", `message ${messageId} is a forward — a forward cannot be edited`);
|
|
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);
|
|
411
655
|
}
|
|
412
656
|
catch (error) {
|
|
413
|
-
|
|
657
|
+
const failure = asCliError(error);
|
|
658
|
+
this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: failure.code });
|
|
414
659
|
throw error;
|
|
415
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();
|
|
416
671
|
try {
|
|
417
|
-
await this.#
|
|
418
|
-
|
|
419
|
-
chatId,
|
|
420
|
-
messageId
|
|
421
|
-
reaction: { reactionType: "EMOJI", id: emoji },
|
|
672
|
+
const sent = await this.#deliver(toChatId, "", cid, {
|
|
673
|
+
...options,
|
|
674
|
+
forward: { chatId: fromChatId, messageId },
|
|
675
|
+
repeat: `max messages forward ${fromChatId} ${messageId} --to ${toChatId}`,
|
|
422
676
|
});
|
|
423
|
-
this.#sends?.record({ chatId, kind: "
|
|
424
|
-
return
|
|
677
|
+
this.#sends?.record({ chatId: toChatId, kind: "forward", outcome: "sent", messageId: sent.id, cid });
|
|
678
|
+
return sent;
|
|
425
679
|
}
|
|
426
680
|
catch (error) {
|
|
681
|
+
const failure = asCliError(error);
|
|
427
682
|
this.#sends?.record({
|
|
428
|
-
chatId,
|
|
429
|
-
kind: "
|
|
430
|
-
outcome: "failed",
|
|
431
|
-
|
|
432
|
-
errorCode:
|
|
683
|
+
chatId: toChatId,
|
|
684
|
+
kind: "forward",
|
|
685
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
686
|
+
cid,
|
|
687
|
+
errorCode: failure.code,
|
|
433
688
|
});
|
|
434
689
|
throw error;
|
|
435
690
|
}
|
|
436
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
|
+
}),
|
|
437
760
|
};
|
|
438
761
|
inbox = {
|
|
439
762
|
/**
|
|
@@ -499,24 +822,91 @@ export class MaxClient {
|
|
|
499
822
|
};
|
|
500
823
|
},
|
|
501
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
|
+
}
|
|
502
891
|
async #deliver(chatId, text, cid, options) {
|
|
503
892
|
await this.#connectOnce();
|
|
504
893
|
const session = this.#session();
|
|
894
|
+
const attaches = [];
|
|
895
|
+
for (const file of options.files ?? [])
|
|
896
|
+
attaches.push(await this.#upload(file));
|
|
505
897
|
const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
: {
|
|
509
902
|
text: plain,
|
|
510
|
-
cid,
|
|
511
903
|
elements: markup,
|
|
512
|
-
attaches: [],
|
|
513
904
|
...(options.replyTo ? { link: { type: "REPLY", messageId: options.replyTo } } : {}),
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
};
|
|
905
|
+
};
|
|
906
|
+
const request = { chatId, message: { cid, attaches, ...content }, notify: options.notify ?? true };
|
|
517
907
|
let answer;
|
|
518
908
|
try {
|
|
519
|
-
answer = await this.#wire.messages.send(request);
|
|
909
|
+
answer = await this.#untilAttachmentsReady(() => this.#wire.messages.send(request));
|
|
520
910
|
}
|
|
521
911
|
catch (error) {
|
|
522
912
|
const failure = asCliError(error);
|
|
@@ -529,7 +919,7 @@ export class MaxClient {
|
|
|
529
919
|
}
|
|
530
920
|
catch {
|
|
531
921
|
throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
|
|
532
|
-
|
|
922
|
+
`\`${options.repeat ?? "max messages send <chat> <text>"} --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
|
|
533
923
|
}
|
|
534
924
|
}
|
|
535
925
|
// What the cache holds for this chat is now one message short of the truth.
|
|
@@ -537,6 +927,92 @@ export class MaxClient {
|
|
|
537
927
|
const sent = record(answer.message) ?? answer;
|
|
538
928
|
return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
|
|
539
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 };
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
540
1016
|
/**
|
|
541
1017
|
* Opens the connection and logs in with the stored token, or with one offered for trial.
|
|
542
1018
|
*
|
|
@@ -554,7 +1030,7 @@ export class MaxClient {
|
|
|
554
1030
|
throw new CliError("authentication_error", `no session for profile "${this.#store.profile}" — run \`max ${asFirstWord(this.#store.profile)}session start\``);
|
|
555
1031
|
}
|
|
556
1032
|
const state = this.#store.readState();
|
|
557
|
-
const sync = this.#cache?.syncMarker();
|
|
1033
|
+
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
558
1034
|
try {
|
|
559
1035
|
await this.#connection.open();
|
|
560
1036
|
this.#login = await startSession(this.#invoke, {
|
|
@@ -859,6 +1335,105 @@ export class MaxClient {
|
|
|
859
1335
|
* by a test, not by a lost message. Across processes this is still millisecond-grained, which is
|
|
860
1336
|
* safe while one invocation sends one message.
|
|
861
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
|
+
}
|
|
862
1437
|
#nextCid() {
|
|
863
1438
|
const now = Date.now();
|
|
864
1439
|
this.#previousCid = now > this.#previousCid ? now : this.#previousCid + 1;
|
|
@@ -923,12 +1498,117 @@ export class MaxClient {
|
|
|
923
1498
|
const others = Object.keys(participants).filter((id) => id !== viewerId);
|
|
924
1499
|
return others.length === 1 ? others[0] : undefined;
|
|
925
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
|
+
}
|
|
926
1581
|
#session() {
|
|
927
1582
|
if (!this.#login)
|
|
928
1583
|
throw new CliError("configuration_error", "connect() was never called");
|
|
929
1584
|
return this.#login;
|
|
930
1585
|
}
|
|
931
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
|
+
};
|
|
932
1612
|
const largestMp4 = (answer) => Object.entries(answer)
|
|
933
1613
|
.map(([key, value]) => ({ height: /^MP4_(\d+)$/.exec(key)?.[1], value }))
|
|
934
1614
|
.filter((entry) => entry.height !== undefined && typeof entry.value === "string")
|
|
@@ -976,6 +1656,14 @@ export const timeOfMessageId = (id) => {
|
|
|
976
1656
|
* account rarely has that many chats change between two checks, and the rest are named, not lost.
|
|
977
1657
|
*/
|
|
978
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]);
|
|
979
1667
|
/** Newest first — the chats a reader most likely came for are read before the cap. */
|
|
980
1668
|
const byRecency = (chats) => chats.toSorted((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? ""));
|
|
981
1669
|
const capped = (chats) => ({
|
|
@@ -1062,4 +1750,46 @@ const asCliError = (error) => {
|
|
|
1062
1750
|
return new CliError("timeout", message);
|
|
1063
1751
|
return new CliError("network_error", message);
|
|
1064
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
|
+
};
|
|
1065
1795
|
//# sourceMappingURL=client.js.map
|