@leemour/max-cli 0.6.0 → 0.8.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 +201 -74
- 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/schema.d.ts +13 -8
- package/dist/cache/schema.d.ts.map +1 -1
- package/dist/cache/schema.js +84 -23
- package/dist/cache/schema.js.map +1 -1
- package/dist/cache/store.d.ts +15 -0
- package/dist/cache/store.d.ts.map +1 -1
- package/dist/cache/store.js +32 -3
- package/dist/cache/store.js.map +1 -1
- package/dist/client.d.ts +205 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +866 -31
- 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 +167 -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 +10 -4
- 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 +22 -3
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +26 -7
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/export.d.ts +3 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +83 -0
- package/dist/commands/export.js.map +1 -0
- 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 +24 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/messages.d.ts.map +1 -1
- package/dist/commands/messages.js +210 -5
- package/dist/commands/messages.js.map +1 -1
- package/dist/commands/models.d.ts +7 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +47 -0
- package/dist/commands/models.js.map +1 -0
- 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 +98 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +16 -3
- 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 +64 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/config.d.ts +32 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +82 -5
- 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 +47 -1
- package/dist/domain/map.js.map +1 -1
- package/dist/domain/models.d.ts +61 -0
- package/dist/domain/models.d.ts.map +1 -1
- package/dist/domain/models.js.map +1 -1
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +11 -0
- package/dist/download.js.map +1 -1
- package/dist/export.d.ts +26 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +75 -0
- package/dist/export.js.map +1 -0
- package/dist/generated/client.generated.d.ts +28 -0
- package/dist/generated/client.generated.d.ts.map +1 -1
- package/dist/generated/client.generated.js +28 -0
- package/dist/generated/client.generated.js.map +1 -1
- package/dist/generated/opcodes.generated.d.ts +23 -13
- package/dist/generated/opcodes.generated.d.ts.map +1 -1
- package/dist/generated/opcodes.generated.js +23 -13
- package/dist/generated/opcodes.generated.js.map +1 -1
- package/dist/generated/operations.generated.d.ts +180 -6
- package/dist/generated/operations.generated.d.ts.map +1 -1
- package/dist/generated/operations.generated.js +29 -4
- package/dist/generated/operations.generated.js.map +1 -1
- package/dist/mcp/confirm.d.ts +31 -0
- package/dist/mcp/confirm.d.ts.map +1 -0
- package/dist/mcp/confirm.js +62 -0
- package/dist/mcp/confirm.js.map +1 -0
- package/dist/mcp/instructions.d.ts +14 -0
- package/dist/mcp/instructions.d.ts.map +1 -0
- package/dist/mcp/instructions.js +39 -0
- package/dist/mcp/instructions.js.map +1 -0
- package/dist/mcp/server.d.ts +25 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +57 -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 +15 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +351 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +23 -2
- 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 +8 -3
- package/dist/sends/guard.d.ts.map +1 -1
- package/dist/sends/guard.js +40 -15
- package/dist/sends/guard.js.map +1 -1
- package/dist/sends/journal.d.ts +20 -2
- package/dist/sends/journal.d.ts.map +1 -1
- package/dist/sends/journal.js.map +1 -1
- package/dist/sends/permissions.d.ts +11 -0
- package/dist/sends/permissions.d.ts.map +1 -0
- package/dist/sends/permissions.js +47 -0
- package/dist/sends/permissions.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 +45 -0
- package/dist/server/server-connection.d.ts.map +1 -0
- package/dist/server/server-connection.js +201 -0
- package/dist/server/server-connection.js.map +1 -0
- package/dist/server/server.d.ts +69 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +360 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/start.d.ts +28 -0
- package/dist/server/start.d.ts.map +1 -0
- package/dist/server/start.js +80 -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 +4 -0
- package/dist/session/adopt.js.map +1 -1
- 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/handshake.d.ts +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 +30 -7
- 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 +66 -2
- package/dist/spec/operations/chats.d.ts.map +1 -1
- package/dist/spec/operations/chats.js +153 -5
- 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 +56 -6
- package/dist/spec/operations/messages.d.ts.map +1 -1
- package/dist/spec/operations/messages.js +104 -21
- 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/transcribe/index.d.ts +34 -0
- package/dist/transcribe/index.d.ts.map +1 -0
- package/dist/transcribe/index.js +43 -0
- package/dist/transcribe/index.js.map +1 -0
- package/dist/transcribe/install.d.ts +18 -0
- package/dist/transcribe/install.d.ts.map +1 -0
- package/dist/transcribe/install.js +67 -0
- package/dist/transcribe/install.js.map +1 -0
- package/dist/transcribe/models.d.ts +32 -0
- package/dist/transcribe/models.d.ts.map +1 -0
- package/dist/transcribe/models.js +124 -0
- package/dist/transcribe/models.js.map +1 -0
- package/dist/transcribe/speech.d.ts +25 -0
- package/dist/transcribe/speech.d.ts.map +1 -0
- package/dist/transcribe/speech.js +105 -0
- package/dist/transcribe/speech.js.map +1 -0
- package/dist/update.d.ts +26 -0
- package/dist/update.d.ts.map +1 -0
- package/dist/update.js +42 -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 +17 -3
- package/skills/max-cli/SKILL.md +28 -3
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,136 @@ 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
|
+
/**
|
|
205
|
+
* Marks a chat read up to `messageId`, or up to its newest message. The one call here that
|
|
206
|
+
* sends `CHAT_MARK`; nothing that reads does (REQUIREMENTS §19). The other person sees it, so it
|
|
207
|
+
* passes the send guard like a pin, and is not retried.
|
|
208
|
+
*/
|
|
209
|
+
markRead: async (chatId, messageId) => {
|
|
210
|
+
if (this.#offline)
|
|
211
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot mark a chat read");
|
|
212
|
+
this.#guard(chatId, "read", messageId);
|
|
213
|
+
try {
|
|
214
|
+
await this.#connectOnce();
|
|
215
|
+
const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
|
|
216
|
+
if (!upTo)
|
|
217
|
+
throw new CliError("not_found", `chat ${chatId} has no messages to mark read`);
|
|
218
|
+
const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark: Date.now() });
|
|
219
|
+
this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
|
|
220
|
+
return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.#sends?.record({ chatId, kind: "read", outcome: "failed", errorCode: asCliError(error).code });
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
leave: async (reference) => {
|
|
228
|
+
const chatId = await this.chats.resolve(reference);
|
|
229
|
+
return this.#changeChat(chatId, "leave", async () => {
|
|
230
|
+
await this.#wire.chats.leave({ chatId });
|
|
231
|
+
return { chatId, result: { chatId, action: "leave", people: [], chat: null } };
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
|
|
236
|
+
* whether MAX deduplicates a creation by `cid` is not measured.
|
|
237
|
+
*/
|
|
238
|
+
create: (title, people = []) => this.#changeChat(null, "create", async () => {
|
|
239
|
+
const userIds = await this.#personIds(people);
|
|
240
|
+
const answer = await this.#wire.messages.send({
|
|
241
|
+
message: {
|
|
242
|
+
cid: this.#nextCid(),
|
|
243
|
+
attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
|
|
244
|
+
},
|
|
245
|
+
notify: true,
|
|
246
|
+
});
|
|
247
|
+
const chat = toGroupCard(record(answer.chat) ?? {});
|
|
248
|
+
return { chatId: chat.id, result: chat, people: userIds.length };
|
|
249
|
+
}),
|
|
250
|
+
members: {
|
|
251
|
+
add: (reference, people, { history = true } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
|
|
252
|
+
remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
|
|
253
|
+
},
|
|
254
|
+
admins: {
|
|
255
|
+
add: (reference, person, rights) => this.#updateMembers(reference, [person], "admins.add", {
|
|
256
|
+
operation: "add",
|
|
257
|
+
type: "ADMIN",
|
|
258
|
+
permissions: rights.reduce((sum, right) => sum | ADMIN_RIGHTS[right], 0),
|
|
259
|
+
}),
|
|
260
|
+
remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
|
|
261
|
+
},
|
|
262
|
+
requests: {
|
|
263
|
+
list: async (reference) => {
|
|
264
|
+
if (this.#offline)
|
|
265
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; join requests never are");
|
|
266
|
+
const chatId = await this.chats.resolve(reference);
|
|
267
|
+
await this.#connectOnce();
|
|
268
|
+
const answer = await this.#wire.chats.members({ chatId, type: "JOIN_REQUEST", count: JOIN_REQUESTS });
|
|
269
|
+
return asArray(answer.members).map((member) => toContact(record(member.contact) ?? {}));
|
|
270
|
+
},
|
|
271
|
+
accept: (reference, people) => this.#updateMembers(reference, people, "requests.accept", {
|
|
272
|
+
operation: "add",
|
|
273
|
+
type: "JOIN_REQUEST",
|
|
274
|
+
showHistory: true,
|
|
275
|
+
}),
|
|
276
|
+
decline: (reference, people) => this.#updateMembers(reference, people, "requests.decline", { operation: "remove", type: "JOIN_REQUEST" }),
|
|
277
|
+
},
|
|
278
|
+
update: async (reference, { title, description }) => {
|
|
279
|
+
if (title === undefined && description === undefined) {
|
|
280
|
+
throw new CliError("validation_error", "nothing to change — give --title, --description or both");
|
|
281
|
+
}
|
|
282
|
+
const chatId = await this.chats.resolve(reference);
|
|
283
|
+
return this.#changeChat(chatId, "update", async () => {
|
|
284
|
+
const answer = await this.#wire.chats.update({
|
|
285
|
+
chatId,
|
|
286
|
+
...(title === undefined ? {} : { theme: title }),
|
|
287
|
+
...(description === undefined ? {} : { description }),
|
|
288
|
+
});
|
|
289
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
290
|
+
});
|
|
291
|
+
},
|
|
292
|
+
/** With no changes, reads the settings the login carried; nothing is sent. */
|
|
293
|
+
settings: async (reference, changes = {}) => {
|
|
294
|
+
if (this.#offline)
|
|
295
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; settings never are");
|
|
296
|
+
const chatId = await this.chats.resolve(reference);
|
|
297
|
+
const entries = Object.entries(changes).filter(([, value]) => value !== undefined);
|
|
298
|
+
if (entries.length === 0) {
|
|
299
|
+
await this.#connectOnce();
|
|
300
|
+
const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
|
|
301
|
+
if (!raw)
|
|
302
|
+
throw new CliError("not_found", `no chat ${chatId} among this account's chats`);
|
|
303
|
+
return toGroupCard(raw);
|
|
304
|
+
}
|
|
305
|
+
const options = Object.fromEntries(entries.map(([ours, value]) => [SETTING_FLAGS[ours], value]));
|
|
306
|
+
return this.#changeChat(chatId, "settings", async () => {
|
|
307
|
+
const answer = await this.#wire.chats.update({ chatId, options });
|
|
308
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
/** The old link stops working for everyone who has it. */
|
|
312
|
+
resetLink: async (reference) => {
|
|
313
|
+
const chatId = await this.chats.resolve(reference);
|
|
314
|
+
return this.#changeChat(chatId, "link.reset", async () => {
|
|
315
|
+
const answer = await this.#wire.chats.update({ chatId, revokePrivateLink: true });
|
|
316
|
+
return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
|
|
317
|
+
});
|
|
318
|
+
},
|
|
131
319
|
};
|
|
132
320
|
contacts = {
|
|
133
321
|
/**
|
|
@@ -172,6 +360,39 @@ export class MaxClient {
|
|
|
172
360
|
* It is also the only thing that could ever prune somebody MAX has stopped returning, which is
|
|
173
361
|
* the second reason it exists.
|
|
174
362
|
*/
|
|
363
|
+
/**
|
|
364
|
+
* Finds whoever MAX has under a phone number. A lookup, not an add: nothing changes on the
|
|
365
|
+
* account. ⚠ The number is never repeated in an error — it is the one field here the sixth
|
|
366
|
+
* constraint is about.
|
|
367
|
+
*/
|
|
368
|
+
lookup: async (phone) => {
|
|
369
|
+
if (this.#offline)
|
|
370
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot look a number up");
|
|
371
|
+
const number = wirePhone(phone);
|
|
372
|
+
await this.#connectOnce();
|
|
373
|
+
const contact = record((await this.#wire.contacts.byPhone({ phone: number })).contact);
|
|
374
|
+
if (!contact)
|
|
375
|
+
throw new CliError("not_found", "MAX has nobody under that number");
|
|
376
|
+
return this.#remember(toContact(contact));
|
|
377
|
+
},
|
|
378
|
+
add: (reference) => this.#contactAction("contact-add", "ADD", reference),
|
|
379
|
+
remove: (reference) => this.#contactAction("contact-remove", "REMOVE", reference),
|
|
380
|
+
/** Uploads phone numbers to MAX — other people's — under the names they were saved with. */
|
|
381
|
+
import: (entries) => this.#change("contact-import", async () => {
|
|
382
|
+
if (entries.length === 0)
|
|
383
|
+
throw new CliError("validation_error", "no numbers to import");
|
|
384
|
+
const contactList = {};
|
|
385
|
+
for (const entry of entries)
|
|
386
|
+
contactList[wirePhone(entry.phone)] = { firstName: entry.name };
|
|
387
|
+
const answer = await this.#wire.contacts.import({ contactList });
|
|
388
|
+
return {
|
|
389
|
+
sent: Object.keys(contactList).length,
|
|
390
|
+
recognised: Object.keys(record(answer.phones) ?? {}),
|
|
391
|
+
contacts: asArray(answer.contacts)
|
|
392
|
+
.map(toContact)
|
|
393
|
+
.map((contact) => this.#remember(contact)),
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
175
396
|
/**
|
|
176
397
|
* One person and the chats we share, **whoever they are** — a group member is as findable as
|
|
177
398
|
* a contact. `NEED-105` decides who `list` lists, not who can be looked up.
|
|
@@ -243,6 +464,32 @@ export class MaxClient {
|
|
|
243
464
|
const total = cache.messages.countSearch({ query: checked, ...(chatId ? { chatId } : {}) });
|
|
244
465
|
return { items, hasMore: offset + items.length < total };
|
|
245
466
|
},
|
|
467
|
+
/**
|
|
468
|
+
* The chat's queue of scheduled messages, soonest first. Read-only: cancelling is `MSG_DELETE`,
|
|
469
|
+
* which nothing here sends. Never cached — the queue empties by itself as messages go out.
|
|
470
|
+
*/
|
|
471
|
+
scheduled: async (chatId) => {
|
|
472
|
+
if (this.#offline)
|
|
473
|
+
throw new CliError("validation_error", "`--offline` has no record of the queue; it lives on MAX");
|
|
474
|
+
await this.#connectOnce();
|
|
475
|
+
const session = this.#session();
|
|
476
|
+
const answer = await this.#wire.chats.history({
|
|
477
|
+
chatId,
|
|
478
|
+
from: 1,
|
|
479
|
+
forward: 100,
|
|
480
|
+
backward: 0,
|
|
481
|
+
forwardTime: 0,
|
|
482
|
+
backwardTime: 0,
|
|
483
|
+
itemType: "DELAYED",
|
|
484
|
+
getChat: false,
|
|
485
|
+
getMessages: true,
|
|
486
|
+
interactive: false,
|
|
487
|
+
});
|
|
488
|
+
const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
|
|
489
|
+
return asArray(answer.messages)
|
|
490
|
+
.map((raw) => toMessage(raw, chatId, lookup))
|
|
491
|
+
.sort((a, b) => (a.scheduledFor ?? "").localeCompare(b.scheduledFor ?? ""));
|
|
492
|
+
},
|
|
246
493
|
/**
|
|
247
494
|
* **Paged backwards through time, not by page number.** MAX's history is already anchored —
|
|
248
495
|
* it takes a moment and answers with what came before it — so `--before` is exact where a page
|
|
@@ -369,10 +616,21 @@ export class MaxClient {
|
|
|
369
616
|
* server remembers a `cid`; the two probes were seconds apart. If the retry also fails the
|
|
370
617
|
* answer is `outcome_unknown` — never failed, never sent — and it names the `cid`, because
|
|
371
618
|
* `max messages send … --cid <n>` can then repeat the attempt without risking a second message.
|
|
619
|
+
*
|
|
620
|
+
* **`at` queues it on MAX** (epoch ms), which sends it then even with this machine off. That is
|
|
621
|
+
* never retried: deduplication by `cid` was measured for ordinary messages only, and a second
|
|
622
|
+
* copy would surface later, where nobody is watching. The guard counts it now, when it is queued.
|
|
372
623
|
*/
|
|
373
624
|
send: async (chatId, text, options = {}) => {
|
|
374
625
|
if (this.#offline)
|
|
375
626
|
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot send");
|
|
627
|
+
if (options.at !== undefined && options.notify === false) {
|
|
628
|
+
// The web client always sends a scheduled message with `notify: true`; §34.
|
|
629
|
+
throw new CliError("validation_error", "a scheduled message cannot be silent — MAX's own client never sends one");
|
|
630
|
+
}
|
|
631
|
+
if (options.at !== undefined && options.cid !== undefined) {
|
|
632
|
+
throw new CliError("validation_error", `--cid repeats an ambiguous send, which is not safe for a scheduled one — \`max messages scheduled ${chatId}\` shows whether it is queued`);
|
|
633
|
+
}
|
|
376
634
|
// Before connecting: a refused send never opens a socket when the chat was given as an id.
|
|
377
635
|
try {
|
|
378
636
|
this.#sends?.check(chatId);
|
|
@@ -382,9 +640,22 @@ export class MaxClient {
|
|
|
382
640
|
throw error;
|
|
383
641
|
}
|
|
384
642
|
const cid = options.cid ?? this.#nextCid();
|
|
643
|
+
const files = await Promise.all((options.files ?? []).map(async (path) => ({ path, bytes: await readUpload(path), photo: isImage(path) })));
|
|
644
|
+
// Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
|
|
645
|
+
if (files.some((file) => !file.photo) && files.length > 1) {
|
|
646
|
+
throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
|
|
647
|
+
}
|
|
648
|
+
const attachments = files.map(({ bytes, photo }) => ({
|
|
649
|
+
kind: photo ? "photo" : "file",
|
|
650
|
+
bytes: bytes.length,
|
|
651
|
+
}));
|
|
652
|
+
const summary = {
|
|
653
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
654
|
+
...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
|
|
655
|
+
};
|
|
385
656
|
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 });
|
|
657
|
+
const sent = await this.#deliver(chatId, text, cid, { ...options, files });
|
|
658
|
+
this.#sends?.record({ chatId, outcome: "sent", messageId: sent.id, cid, length: text.length, ...summary });
|
|
388
659
|
return sent;
|
|
389
660
|
}
|
|
390
661
|
catch (error) {
|
|
@@ -394,46 +665,196 @@ export class MaxClient {
|
|
|
394
665
|
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
395
666
|
cid,
|
|
396
667
|
length: text.length,
|
|
668
|
+
...summary,
|
|
397
669
|
errorCode: failure.code,
|
|
398
670
|
});
|
|
399
671
|
throw error;
|
|
400
672
|
}
|
|
401
673
|
},
|
|
674
|
+
/** Puts one emoji reaction on a message; it replaces the one you had. */
|
|
675
|
+
react: (chatId, messageId, emoji) => this.#reaction(chatId, messageId, () => this.#wire.messages.react({ chatId, messageId, reaction: { reactionType: "EMOJI", id: emoji } })),
|
|
676
|
+
/** Takes your reaction off. Measured 2026-09-24: a second call is answered the same, not refused. */
|
|
677
|
+
unreact: (chatId, messageId) => this.#reaction(chatId, messageId, () => this.#wire.messages.unreact({ chatId, messageId })),
|
|
402
678
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
679
|
+
* Changes the text of one of the owner's own messages. The person may have read it already.
|
|
680
|
+
*
|
|
681
|
+
* **Its attachments are sent back as history gives them**: measured 2026-09-24, an edit with
|
|
682
|
+
* none removes a photo from the message. Refused before asking MAX when the message is not the
|
|
683
|
+
* owner's, is a forward, or carries anything but photos — the web client offers none of those. MAX's own limit is
|
|
684
|
+
* `edit-timeout` from LOGIN, 604800 s when measured; past it MAX refuses and that is the answer.
|
|
685
|
+
* Not retried, like a reaction.
|
|
405
686
|
*/
|
|
406
|
-
|
|
687
|
+
edit: async (chatId, messageId, text, { markdown = false } = {}) => {
|
|
407
688
|
if (this.#offline)
|
|
408
|
-
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot
|
|
689
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
|
|
690
|
+
this.#guard(chatId, "edit", messageId);
|
|
409
691
|
try {
|
|
410
|
-
this.#
|
|
692
|
+
await this.#connectOnce();
|
|
693
|
+
const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
|
|
694
|
+
const raw = await this.#rawMessage(chatId, messageId);
|
|
695
|
+
const current = toMessage(raw, chatId, lookup);
|
|
696
|
+
if (current.outgoing !== true) {
|
|
697
|
+
throw new CliError("validation_error", `message ${messageId} is not yours — only your own can be edited`);
|
|
698
|
+
}
|
|
699
|
+
if (record(raw.link)?.type === "FORWARD") {
|
|
700
|
+
throw new CliError("validation_error", `message ${messageId} is a forward — a forward cannot be edited`);
|
|
701
|
+
}
|
|
702
|
+
// Only a photo was measured to survive the round trip; the web client refuses files, stickers and the rest too.
|
|
703
|
+
const other = current.attachments.find((attachment) => attachment.kind !== "photo");
|
|
704
|
+
if (other) {
|
|
705
|
+
throw new CliError("validation_error", `message ${messageId} carries a ${other.kind} — only text and photos can be edited`);
|
|
706
|
+
}
|
|
707
|
+
const { text: plain, markup } = markdown ? parseMarkdown(text) : { text, markup: [] };
|
|
708
|
+
const answer = await this.#wire.messages.edit({
|
|
709
|
+
chatId,
|
|
710
|
+
messageId,
|
|
711
|
+
text: plain,
|
|
712
|
+
elements: markup,
|
|
713
|
+
attachments: asArray(raw.attaches),
|
|
714
|
+
});
|
|
715
|
+
this.#cache?.messages.invalidate(chatId);
|
|
716
|
+
this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId, length: text.length });
|
|
717
|
+
return toMessage(record(answer.message) ?? raw, chatId, lookup);
|
|
411
718
|
}
|
|
412
719
|
catch (error) {
|
|
413
|
-
|
|
720
|
+
const failure = asCliError(error);
|
|
721
|
+
this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: failure.code });
|
|
414
722
|
throw error;
|
|
415
723
|
}
|
|
724
|
+
},
|
|
725
|
+
/**
|
|
726
|
+
* Forwards one message into another chat: a new message there, so it goes through `#deliver`
|
|
727
|
+
* — the same `cid`, the same one retry — and counts against `sendsPerHour`.
|
|
728
|
+
*/
|
|
729
|
+
forward: async (fromChatId, messageId, toChatId, options = {}) => {
|
|
730
|
+
if (this.#offline)
|
|
731
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
|
|
732
|
+
this.#guard(toChatId, "forward");
|
|
733
|
+
const cid = options.cid ?? this.#nextCid();
|
|
416
734
|
try {
|
|
417
|
-
await this.#
|
|
418
|
-
|
|
419
|
-
chatId,
|
|
420
|
-
messageId
|
|
421
|
-
|
|
735
|
+
const sent = await this.#deliver(toChatId, "", cid, {
|
|
736
|
+
...options,
|
|
737
|
+
forward: { chatId: fromChatId, messageId },
|
|
738
|
+
repeat: `max messages forward ${fromChatId} ${messageId} --to ${toChatId}`,
|
|
739
|
+
});
|
|
740
|
+
this.#sends?.record({ chatId: toChatId, kind: "forward", outcome: "sent", messageId: sent.id, cid });
|
|
741
|
+
return sent;
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
const failure = asCliError(error);
|
|
745
|
+
this.#sends?.record({
|
|
746
|
+
chatId: toChatId,
|
|
747
|
+
kind: "forward",
|
|
748
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
749
|
+
cid,
|
|
750
|
+
errorCode: failure.code,
|
|
422
751
|
});
|
|
423
|
-
|
|
424
|
-
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
/**
|
|
756
|
+
* Deletes messages — for this account only, or with `forEveryone` for everyone in the chat. The
|
|
757
|
+
* owner's word is the command's `--allow-dangerous`; here it is the guard, and each message
|
|
758
|
+
* counts toward the hourly limit (`MAX-47`). Whose message it is, MAX decides: an admin may
|
|
759
|
+
* delete somebody else's in a group. Not retried, like an edit.
|
|
760
|
+
*/
|
|
761
|
+
delete: async (chatId, messageIds, { forEveryone = false } = {}) => {
|
|
762
|
+
if (this.#offline)
|
|
763
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot delete");
|
|
764
|
+
if (messageIds.length === 0)
|
|
765
|
+
throw new CliError("validation_error", "name at least one message to delete");
|
|
766
|
+
if (messageIds.length > DELETE_AT_ONCE) {
|
|
767
|
+
throw new CliError("validation_error", `${messageIds.length} messages at once — at most ${DELETE_AT_ONCE} per call, so MAX sees deletions spread out`);
|
|
768
|
+
}
|
|
769
|
+
const count = messageIds.length;
|
|
770
|
+
this.#guard(chatId, "delete", undefined, count);
|
|
771
|
+
try {
|
|
772
|
+
await this.#connectOnce();
|
|
773
|
+
await this.#wire.messages.delete({ chatId, messageIds, forMe: !forEveryone });
|
|
774
|
+
this.#cache?.messages.forget(chatId, messageIds);
|
|
775
|
+
this.#sends?.record({ chatId, kind: "delete", outcome: "sent", count, forEveryone });
|
|
776
|
+
return { chatId, deleted: messageIds, forEveryone };
|
|
425
777
|
}
|
|
426
778
|
catch (error) {
|
|
427
779
|
this.#sends?.record({
|
|
428
780
|
chatId,
|
|
429
|
-
kind: "
|
|
781
|
+
kind: "delete",
|
|
430
782
|
outcome: "failed",
|
|
431
|
-
|
|
783
|
+
count,
|
|
784
|
+
forEveryone,
|
|
432
785
|
errorCode: asCliError(error).code,
|
|
433
786
|
});
|
|
434
787
|
throw error;
|
|
435
788
|
}
|
|
436
789
|
},
|
|
790
|
+
/**
|
|
791
|
+
* Pins one message in a chat, or with `null` unpins whatever is pinned — `pinMessageId: 0` is
|
|
792
|
+
* how the web client unpins. No notification unless asked (`NEED-196`). Not retried.
|
|
793
|
+
*
|
|
794
|
+
* **Never in a personal chat**: the web client's dialog class answers `viewerCanPin` with `false`, and
|
|
795
|
+
* MAX refused both in Saved messages and in a dialog with a person (measured 2026-09-24, `FIND-107`).
|
|
796
|
+
* A chat the login did not list is left to MAX. In a group, pin and unpin were measured the same day.
|
|
797
|
+
*/
|
|
798
|
+
pin: async (chatId, messageId, { notify = false } = {}) => {
|
|
799
|
+
if (this.#offline)
|
|
800
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
|
|
801
|
+
this.#guard(chatId, "pin", messageId ?? undefined);
|
|
802
|
+
try {
|
|
803
|
+
await this.#connectOnce();
|
|
804
|
+
const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
|
|
805
|
+
if (known && toChat(known).kind === "dialog") {
|
|
806
|
+
throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
|
|
807
|
+
}
|
|
808
|
+
await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
|
|
809
|
+
this.#sends?.record({ chatId, kind: "pin", outcome: "sent", ...(messageId ? { messageId } : {}) });
|
|
810
|
+
return { chatId, pinned: messageId };
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
this.#sends?.record({ chatId, kind: "pin", outcome: "failed", errorCode: asCliError(error).code });
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
folders = {
|
|
819
|
+
list: async () => (await this.#folders()).map(toFolder),
|
|
820
|
+
create: (title, chats = []) => this.#change("folder-create", async () => {
|
|
821
|
+
const include = await Promise.all(chats.map((chat) => this.chats.resolve(chat)));
|
|
822
|
+
const answer = await this.#wire.folders.update({
|
|
823
|
+
id: crypto.randomUUID(),
|
|
824
|
+
title: folderTitle(title),
|
|
825
|
+
include,
|
|
826
|
+
filters: [],
|
|
827
|
+
options: [],
|
|
828
|
+
});
|
|
829
|
+
return toFolder(record(answer.folder) ?? {});
|
|
830
|
+
}),
|
|
831
|
+
/**
|
|
832
|
+
* **The folder goes back whole**, as the web client sends it: an edit that left out the chats
|
|
833
|
+
* would empty the folder. What MAX keeps for itself — `sourceId`, `updateTime` — is not sent.
|
|
834
|
+
*/
|
|
835
|
+
update: (reference, change) => this.#change("folder-update", async () => {
|
|
836
|
+
const folder = pickFolder(reference, await this.#folders());
|
|
837
|
+
const added = await Promise.all((change.add ?? []).map((chat) => this.chats.resolve(chat)));
|
|
838
|
+
const removed = new Set(await Promise.all((change.remove ?? []).map((chat) => this.chats.resolve(chat))));
|
|
839
|
+
const touchesChats = added.length > 0 || removed.size > 0;
|
|
840
|
+
const current = toFolder(folder).chatIds;
|
|
841
|
+
const include = [...new Set([...current, ...added])].filter((id) => !removed.has(id));
|
|
842
|
+
const answer = await this.#wire.folders.update({
|
|
843
|
+
id: String(folder.id),
|
|
844
|
+
title: change.title === undefined ? String(folder.title ?? "") : folderTitle(change.title),
|
|
845
|
+
...(Array.isArray(folder.include) || touchesChats ? { include } : {}),
|
|
846
|
+
filters: Array.isArray(folder.filters) ? folder.filters : [],
|
|
847
|
+
options: Array.isArray(folder.options) ? folder.options : [],
|
|
848
|
+
...(Array.isArray(folder.favorites) ? { favorites: folder.favorites } : {}),
|
|
849
|
+
});
|
|
850
|
+
return toFolder(record(answer.folder) ?? {});
|
|
851
|
+
}),
|
|
852
|
+
/** The folder only — its chats stay where they are. */
|
|
853
|
+
delete: (reference) => this.#change("folder-delete", async () => {
|
|
854
|
+
const folder = pickFolder(reference, await this.#folders());
|
|
855
|
+
await this.#wire.folders.delete({ folderIds: [String(folder.id)] });
|
|
856
|
+
return toFolder(folder);
|
|
857
|
+
}),
|
|
437
858
|
};
|
|
438
859
|
inbox = {
|
|
439
860
|
/**
|
|
@@ -499,29 +920,101 @@ export class MaxClient {
|
|
|
499
920
|
};
|
|
500
921
|
},
|
|
501
922
|
};
|
|
923
|
+
/**
|
|
924
|
+
* A reaction is seen by the other person, so it goes through the send guard and the send log like a
|
|
925
|
+
* message. Not retried: a reaction lost in transit costs a second command, and nothing about it is
|
|
926
|
+
* measured to make a blind repeat safe.
|
|
927
|
+
*/
|
|
928
|
+
async #reaction(chatId, messageId, call) {
|
|
929
|
+
if (this.#offline)
|
|
930
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
|
|
931
|
+
try {
|
|
932
|
+
this.#sends?.check(chatId, "reaction");
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
await this.#connectOnce();
|
|
940
|
+
const answer = await call();
|
|
941
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
|
|
942
|
+
return toReactions(record(answer.reactionInfo) ?? {});
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
this.#sends?.record({ chatId, kind: "reaction", outcome: "failed", messageId, errorCode: asCliError(error).code });
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/** Asks the send guard, and writes a refusal to the send journal before passing it on. */
|
|
950
|
+
#guard(chatId, kind, messageId, count) {
|
|
951
|
+
try {
|
|
952
|
+
this.#sends?.check(chatId, kind, undefined, count);
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
this.#sends?.record({
|
|
956
|
+
chatId,
|
|
957
|
+
kind,
|
|
958
|
+
outcome: "refused",
|
|
959
|
+
...(messageId ? { messageId } : {}),
|
|
960
|
+
errorCode: asCliError(error).code,
|
|
961
|
+
});
|
|
962
|
+
throw error;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/** One message as MAX sends it, for what the domain model drops — its attachments whole, its link. */
|
|
966
|
+
async #rawMessage(chatId, messageId) {
|
|
967
|
+
const time = timeOfMessageId(messageId);
|
|
968
|
+
if (time === undefined)
|
|
969
|
+
throw new CliError("validation_error", `"${messageId}" is not a message id`);
|
|
970
|
+
const answer = await this.#wire.chats.history({
|
|
971
|
+
chatId,
|
|
972
|
+
from: time,
|
|
973
|
+
forward: 0,
|
|
974
|
+
backward: 1,
|
|
975
|
+
forwardTime: 0,
|
|
976
|
+
backwardTime: 0,
|
|
977
|
+
itemType: "REGULAR",
|
|
978
|
+
getChat: false,
|
|
979
|
+
getMessages: true,
|
|
980
|
+
interactive: false,
|
|
981
|
+
});
|
|
982
|
+
const found = asArray(answer.messages)
|
|
983
|
+
.map((raw) => record(raw) ?? {})
|
|
984
|
+
.find((raw) => asId(raw.id) === messageId);
|
|
985
|
+
if (!found)
|
|
986
|
+
throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
|
|
987
|
+
return found;
|
|
988
|
+
}
|
|
502
989
|
async #deliver(chatId, text, cid, options) {
|
|
503
990
|
await this.#connectOnce();
|
|
504
991
|
const session = this.#session();
|
|
992
|
+
const attaches = [];
|
|
993
|
+
for (const file of options.files ?? [])
|
|
994
|
+
attaches.push(await this.#upload(file));
|
|
505
995
|
const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
996
|
+
// A forward carries no text or markup of its own — the web client leaves both out, and so was it measured.
|
|
997
|
+
const content = options.forward
|
|
998
|
+
? { link: { type: "FORWARD", ...options.forward } }
|
|
999
|
+
: {
|
|
509
1000
|
text: plain,
|
|
510
|
-
cid,
|
|
511
1001
|
elements: markup,
|
|
512
|
-
attaches: [],
|
|
513
1002
|
...(options.replyTo ? { link: { type: "REPLY", messageId: options.replyTo } } : {}),
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
};
|
|
1003
|
+
};
|
|
1004
|
+
const delayed = options.at === undefined ? {} : { delayedAttributes: { timeToFire: options.at } };
|
|
1005
|
+
const request = { chatId, message: { cid, attaches, ...content, ...delayed }, notify: options.notify ?? true };
|
|
517
1006
|
let answer;
|
|
518
1007
|
try {
|
|
519
|
-
answer = await this.#wire.messages.send(request);
|
|
1008
|
+
answer = await this.#untilAttachmentsReady(() => this.#wire.messages.send(request));
|
|
520
1009
|
}
|
|
521
1010
|
catch (error) {
|
|
522
1011
|
const failure = asCliError(error);
|
|
523
1012
|
if (failure.code !== "timeout" && failure.code !== "network_error")
|
|
524
1013
|
throw failure;
|
|
1014
|
+
if (options.at !== undefined) {
|
|
1015
|
+
throw new CliError("outcome_unknown", `the message may or may not have been scheduled (${failure.message}) — ` +
|
|
1016
|
+
`\`max messages scheduled ${chatId}\` shows the queue; sending it again could queue a second copy`, { cid });
|
|
1017
|
+
}
|
|
525
1018
|
// No answer came back, so MAX may already have delivered it. Repeating the identical `cid`
|
|
526
1019
|
// is what makes asking again safe rather than reckless.
|
|
527
1020
|
try {
|
|
@@ -529,7 +1022,7 @@ export class MaxClient {
|
|
|
529
1022
|
}
|
|
530
1023
|
catch {
|
|
531
1024
|
throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
|
|
532
|
-
|
|
1025
|
+
`\`${options.repeat ?? "max messages send <chat> <text>"} --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
|
|
533
1026
|
}
|
|
534
1027
|
}
|
|
535
1028
|
// What the cache holds for this chat is now one message short of the truth.
|
|
@@ -537,6 +1030,92 @@ export class MaxClient {
|
|
|
537
1030
|
const sent = record(answer.message) ?? answer;
|
|
538
1031
|
return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
|
|
539
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* **For a connection that stays open** (`max serve`). A one-shot command never calls these.
|
|
1035
|
+
*/
|
|
1036
|
+
live = {
|
|
1037
|
+
/** The keep-alive the web client sends every 30 s; `interactive` is never true here. */
|
|
1038
|
+
ping: async () => {
|
|
1039
|
+
await this.#connectOnce();
|
|
1040
|
+
await this.#wire.session.ping({ interactive: false });
|
|
1041
|
+
},
|
|
1042
|
+
/**
|
|
1043
|
+
* The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
|
|
1044
|
+
* a command as that command's own login.
|
|
1045
|
+
*/
|
|
1046
|
+
snapshot: () => this.#session(),
|
|
1047
|
+
/**
|
|
1048
|
+
* Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
|
|
1049
|
+
* the server stops handing it out until it has logged in again. What web.max.ru does with each
|
|
1050
|
+
* push (bundle read 2026-09-24) is what is copied here:
|
|
1051
|
+
* - 128, a new message: the chat's last message and time move; unread goes up for somebody
|
|
1052
|
+
* else's message and to zero for our own, as the app does once you have written in a chat.
|
|
1053
|
+
* - 130, read up to a point: when we are the reader, the chat's unread becomes `unread`;
|
|
1054
|
+
* somebody else reading changes nothing here.
|
|
1055
|
+
* - 135, a chat changed: MAX sends the whole chat, and it replaces ours.
|
|
1056
|
+
* Anything else that touches the chats — a deletion — cannot be followed.
|
|
1057
|
+
*/
|
|
1058
|
+
patch: (opcode, payload) => {
|
|
1059
|
+
const chats = asArray(this.#session().chats);
|
|
1060
|
+
const chatId = asId(payload.chatId);
|
|
1061
|
+
const chat = chats.find((candidate) => asId(candidate.id) === chatId);
|
|
1062
|
+
const viewerId = this.#store.readState().viewerId;
|
|
1063
|
+
if (opcode === NEW_MESSAGE) {
|
|
1064
|
+
const raw = record(payload.message);
|
|
1065
|
+
if (!raw || !chat)
|
|
1066
|
+
return false;
|
|
1067
|
+
const ours = viewerId !== undefined && asId(raw.sender) === viewerId;
|
|
1068
|
+
chat.lastMessage = raw;
|
|
1069
|
+
chat.lastEventTime = raw.time;
|
|
1070
|
+
chat.newMessages = ours ? 0 : (typeof chat.newMessages === "number" ? chat.newMessages : 0) + 1;
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
if (opcode === READ_MARK) {
|
|
1074
|
+
if (asId(payload.userId) !== viewerId)
|
|
1075
|
+
return true;
|
|
1076
|
+
if (!chat || typeof payload.unread !== "number")
|
|
1077
|
+
return false;
|
|
1078
|
+
chat.newMessages = payload.unread;
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
if (opcode === CHAT_CHANGED) {
|
|
1082
|
+
const changed = record(payload.chat);
|
|
1083
|
+
const id = changed && asId(changed.id);
|
|
1084
|
+
if (!changed || id === undefined)
|
|
1085
|
+
return false;
|
|
1086
|
+
const at = chats.findIndex((candidate) => asId(candidate.id) === id);
|
|
1087
|
+
if (at >= 0)
|
|
1088
|
+
chats[at] = changed;
|
|
1089
|
+
else
|
|
1090
|
+
chats.push(changed);
|
|
1091
|
+
this.#session().chats = chats;
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
return !CHANGES_CHATS.has(opcode);
|
|
1095
|
+
},
|
|
1096
|
+
/** One request from a command, on this connection. The server decides which ones may pass. */
|
|
1097
|
+
forward: async (opcode, payload) => {
|
|
1098
|
+
await this.#connectOnce();
|
|
1099
|
+
return this.#connection.invoke(opcode, payload);
|
|
1100
|
+
},
|
|
1101
|
+
/**
|
|
1102
|
+
* A message MAX pushed (opcode 128) as the same shape `messages list` prints, with its chat's
|
|
1103
|
+
* name — so whoever reads `max watch` gets one schema, and no MAX type crosses this file.
|
|
1104
|
+
* Anything else pushed answers `undefined`.
|
|
1105
|
+
*/
|
|
1106
|
+
message: async (opcode, payload) => {
|
|
1107
|
+
const raw = record(payload.message);
|
|
1108
|
+
const chatId = asId(payload.chatId);
|
|
1109
|
+
if (opcode !== NEW_MESSAGE || !raw || chatId === undefined)
|
|
1110
|
+
return undefined;
|
|
1111
|
+
const session = this.#session();
|
|
1112
|
+
const [message] = await this.#nameSenders([
|
|
1113
|
+
toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
|
|
1114
|
+
]);
|
|
1115
|
+
const chat = (await this.chats.list()).items.find((candidate) => candidate.id === chatId);
|
|
1116
|
+
return message && { ...message, chatTitle: chat?.title ?? null };
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
540
1119
|
/**
|
|
541
1120
|
* Opens the connection and logs in with the stored token, or with one offered for trial.
|
|
542
1121
|
*
|
|
@@ -554,7 +1133,7 @@ export class MaxClient {
|
|
|
554
1133
|
throw new CliError("authentication_error", `no session for profile "${this.#store.profile}" — run \`max ${asFirstWord(this.#store.profile)}session start\``);
|
|
555
1134
|
}
|
|
556
1135
|
const state = this.#store.readState();
|
|
557
|
-
const sync = this.#cache?.syncMarker();
|
|
1136
|
+
const sync = this.#fullLogin ? undefined : this.#cache?.syncMarker();
|
|
558
1137
|
try {
|
|
559
1138
|
await this.#connection.open();
|
|
560
1139
|
this.#login = await startSession(this.#invoke, {
|
|
@@ -859,6 +1438,105 @@ export class MaxClient {
|
|
|
859
1438
|
* by a test, not by a lost message. Across processes this is still millisecond-grained, which is
|
|
860
1439
|
* safe while one invocation sends one message.
|
|
861
1440
|
*/
|
|
1441
|
+
/**
|
|
1442
|
+
* Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
|
|
1443
|
+
* retried: a failure here happens before `MSG_SEND`, so nothing was sent.
|
|
1444
|
+
*/
|
|
1445
|
+
async #upload({ path, bytes, photo }) {
|
|
1446
|
+
const request = { count: 1, type: 0, uploaderType: 0, profile: false };
|
|
1447
|
+
if (photo) {
|
|
1448
|
+
const { url } = await this.#wire.uploads.photo(request);
|
|
1449
|
+
if (typeof url !== "string")
|
|
1450
|
+
throw new CliError("provider_error", "MAX gave no address to upload the photo to");
|
|
1451
|
+
return { _type: "PHOTO", photoToken: await uploadPhoto(url, path, bytes) };
|
|
1452
|
+
}
|
|
1453
|
+
const info = record(asArray((await this.#wire.uploads.file(request)).info)[0]) ?? {};
|
|
1454
|
+
if (typeof info.url !== "string" || info.fileId === undefined) {
|
|
1455
|
+
throw new CliError("provider_error", "MAX gave no address to upload the file to");
|
|
1456
|
+
}
|
|
1457
|
+
await uploadFile(info.url, path, bytes);
|
|
1458
|
+
return { _type: "FILE", fileId: info.fileId };
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* **A file is processed after its upload**, and a send before that is refused
|
|
1462
|
+
* `attachment.not.ready` (measured 2026-09-24). A refusal means nothing was sent, so asking again
|
|
1463
|
+
* with the same request — the same `cid` — is safe. Once a second, for up to thirty.
|
|
1464
|
+
*/
|
|
1465
|
+
async #untilAttachmentsReady(send) {
|
|
1466
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1467
|
+
try {
|
|
1468
|
+
return await send();
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
if (attempt >= 30 || !asCliError(error).message.includes("attachment.not.ready"))
|
|
1472
|
+
throw error;
|
|
1473
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
|
|
1479
|
+
* measured to be safe to repeat, and a repeated `create` is a second group.
|
|
1480
|
+
*/
|
|
1481
|
+
async #changeChat(chatId, action, act) {
|
|
1482
|
+
if (this.#offline)
|
|
1483
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
|
|
1484
|
+
try {
|
|
1485
|
+
this.#sends?.check(chatId, "chat", action);
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
try {
|
|
1492
|
+
await this.#connectOnce();
|
|
1493
|
+
const done = await act();
|
|
1494
|
+
this.#sends?.record({
|
|
1495
|
+
chatId: done.chatId,
|
|
1496
|
+
kind: "chat",
|
|
1497
|
+
action,
|
|
1498
|
+
outcome: "sent",
|
|
1499
|
+
...(done.people === undefined ? {} : { people: done.people }),
|
|
1500
|
+
});
|
|
1501
|
+
return done.result;
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
const failure = asCliError(error);
|
|
1505
|
+
this.#sends?.record({
|
|
1506
|
+
chatId,
|
|
1507
|
+
kind: "chat",
|
|
1508
|
+
action,
|
|
1509
|
+
outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
|
|
1510
|
+
errorCode: failure.code,
|
|
1511
|
+
});
|
|
1512
|
+
throw error;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
async #updateMembers(reference, people, action, change) {
|
|
1516
|
+
const chatId = await this.chats.resolve(reference);
|
|
1517
|
+
return this.#changeChat(chatId, action, async () => {
|
|
1518
|
+
const userIds = await this.#personIds(people);
|
|
1519
|
+
const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
|
|
1520
|
+
const chat = record(answer.chat);
|
|
1521
|
+
return {
|
|
1522
|
+
chatId,
|
|
1523
|
+
people: userIds.length,
|
|
1524
|
+
result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
|
|
1525
|
+
};
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
/** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
|
|
1529
|
+
async #personIds(references) {
|
|
1530
|
+
if (references.every(isId))
|
|
1531
|
+
return references.map((reference) => reference.trim());
|
|
1532
|
+
const cache = this.#cache;
|
|
1533
|
+
if (!cache) {
|
|
1534
|
+
throw new CliError("configuration_error", `there is no local store for profile "${this.#store.profile}" to look names up in — give people by id`);
|
|
1535
|
+
}
|
|
1536
|
+
await this.#connectOnce();
|
|
1537
|
+
await this.#peopleFor(asArray(this.#session().chats));
|
|
1538
|
+
return references.map((reference) => (isId(reference) ? reference.trim() : pickPerson(reference, cache).id));
|
|
1539
|
+
}
|
|
862
1540
|
#nextCid() {
|
|
863
1541
|
const now = Date.now();
|
|
864
1542
|
this.#previousCid = now > this.#previousCid ? now : this.#previousCid + 1;
|
|
@@ -923,12 +1601,117 @@ export class MaxClient {
|
|
|
923
1601
|
const others = Object.keys(participants).filter((id) => id !== viewerId);
|
|
924
1602
|
return others.length === 1 ? others[0] : undefined;
|
|
925
1603
|
}
|
|
1604
|
+
/**
|
|
1605
|
+
* A change to the account itself, not to a chat: a read-only profile refuses it, no recipient
|
|
1606
|
+
* list applies, it does not count towards the hourly limit, and the journal records which kind
|
|
1607
|
+
* of change it was. **Never retried** — at worst the command is typed again.
|
|
1608
|
+
*/
|
|
1609
|
+
async #change(action, body) {
|
|
1610
|
+
if (this.#offline)
|
|
1611
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
|
|
1612
|
+
try {
|
|
1613
|
+
this.#sends?.check(null, "account", action);
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
this.#sends?.record({
|
|
1617
|
+
chatId: null,
|
|
1618
|
+
kind: "account",
|
|
1619
|
+
action,
|
|
1620
|
+
outcome: "refused",
|
|
1621
|
+
errorCode: asCliError(error).code,
|
|
1622
|
+
});
|
|
1623
|
+
throw error;
|
|
1624
|
+
}
|
|
1625
|
+
let recorded = false;
|
|
1626
|
+
const done = () => {
|
|
1627
|
+
if (recorded)
|
|
1628
|
+
return;
|
|
1629
|
+
recorded = true;
|
|
1630
|
+
this.#sends?.record({ chatId: null, kind: "account", action, outcome: "sent" });
|
|
1631
|
+
};
|
|
1632
|
+
try {
|
|
1633
|
+
await this.#connectOnce();
|
|
1634
|
+
const result = await body(done);
|
|
1635
|
+
done();
|
|
1636
|
+
return result;
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
if (!recorded) {
|
|
1640
|
+
this.#sends?.record({
|
|
1641
|
+
chatId: null,
|
|
1642
|
+
kind: "account",
|
|
1643
|
+
action,
|
|
1644
|
+
outcome: "failed",
|
|
1645
|
+
errorCode: asCliError(error).code,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
throw error;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
#contactAction(action, wire, reference) {
|
|
1652
|
+
return this.#change(action, async () => {
|
|
1653
|
+
const cache = this.#cache;
|
|
1654
|
+
const known = isId(reference) ? cache?.people.get(reference.trim()) : undefined;
|
|
1655
|
+
if (!isId(reference) && !cache) {
|
|
1656
|
+
throw new CliError("validation_error", "without a local store a person is named by id — `max contacts lookup` finds one");
|
|
1657
|
+
}
|
|
1658
|
+
const id = isId(reference) ? reference.trim() : pickPerson(reference, cache).id;
|
|
1659
|
+
const answer = await this.#wire.contacts.update({ contactId: id, action: wire });
|
|
1660
|
+
const contact = record(answer.contact);
|
|
1661
|
+
if (contact)
|
|
1662
|
+
return this.#remember(toContact(contact));
|
|
1663
|
+
return known ?? { id, name: null, username: null, description: null, lastMessagedAt: null };
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
#remember(contact) {
|
|
1667
|
+
if (contact.id !== "")
|
|
1668
|
+
this.#cache?.people.upsert([contact], "info");
|
|
1669
|
+
return contact;
|
|
1670
|
+
}
|
|
1671
|
+
async #folders() {
|
|
1672
|
+
if (this.#offline)
|
|
1673
|
+
throw new CliError("validation_error", "`--offline` reads what was recorded, and folders are not recorded");
|
|
1674
|
+
await this.#connectOnce();
|
|
1675
|
+
const answer = await this.#wire.folders.list({ folderSync: 0 });
|
|
1676
|
+
const folders = asArray(answer.folders);
|
|
1677
|
+
const order = Array.isArray(answer.foldersOrder) ? answer.foldersOrder.map(String) : [];
|
|
1678
|
+
const rank = (folder) => {
|
|
1679
|
+
const at = order.indexOf(String(folder.id));
|
|
1680
|
+
return at === -1 ? order.length : at;
|
|
1681
|
+
};
|
|
1682
|
+
return [...folders].sort((a, b) => rank(a) - rank(b));
|
|
1683
|
+
}
|
|
926
1684
|
#session() {
|
|
927
1685
|
if (!this.#login)
|
|
928
1686
|
throw new CliError("configuration_error", "connect() was never called");
|
|
929
1687
|
return this.#login;
|
|
930
1688
|
}
|
|
931
1689
|
}
|
|
1690
|
+
/** PyMax `AdminPermission`; the sum is what MAX takes. */
|
|
1691
|
+
export const ADMIN_RIGHTS = {
|
|
1692
|
+
members: 2,
|
|
1693
|
+
admins: 4,
|
|
1694
|
+
info: 8,
|
|
1695
|
+
pin: 16,
|
|
1696
|
+
post: 256,
|
|
1697
|
+
edit: 512,
|
|
1698
|
+
delete: 1024,
|
|
1699
|
+
};
|
|
1700
|
+
/** PyMax asks for this many; paging join requests is not known. */
|
|
1701
|
+
const JOIN_REQUESTS = 100;
|
|
1702
|
+
/**
|
|
1703
|
+
* A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
|
|
1704
|
+
* measured. A public one (`https://max.ru/<name>`) goes whole, which is PyMax's claim only.
|
|
1705
|
+
*/
|
|
1706
|
+
export const wireLink = (link) => {
|
|
1707
|
+
const trimmed = link.trim();
|
|
1708
|
+
const at = trimmed.indexOf("join/");
|
|
1709
|
+
if (at >= 0 && trimmed.length > at + "join/".length)
|
|
1710
|
+
return trimmed.slice(at);
|
|
1711
|
+
if (/^(https:\/\/)?max\.ru\/[\w.-]+\/?$/.test(trimmed))
|
|
1712
|
+
return trimmed;
|
|
1713
|
+
throw new CliError("validation_error", "not a MAX link — expected https://max.ru/join/… or https://max.ru/<name>");
|
|
1714
|
+
};
|
|
932
1715
|
const largestMp4 = (answer) => Object.entries(answer)
|
|
933
1716
|
.map(([key, value]) => ({ height: /^MP4_(\d+)$/.exec(key)?.[1], value }))
|
|
934
1717
|
.filter((entry) => entry.height !== undefined && typeof entry.value === "string")
|
|
@@ -976,6 +1759,16 @@ export const timeOfMessageId = (id) => {
|
|
|
976
1759
|
* account rarely has that many chats change between two checks, and the rest are named, not lost.
|
|
977
1760
|
*/
|
|
978
1761
|
const INBOX_CHATS = 20;
|
|
1762
|
+
/** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
|
|
1763
|
+
const NEW_MESSAGE = 128;
|
|
1764
|
+
/** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
|
|
1765
|
+
export const DELETE_AT_ONCE = 10;
|
|
1766
|
+
/** Read up to a point — by us on another device, or by somebody else. */
|
|
1767
|
+
const READ_MARK = 130;
|
|
1768
|
+
/** A chat changed; MAX sends it whole. */
|
|
1769
|
+
const CHAT_CHANGED = 135;
|
|
1770
|
+
/** Messages deleted (140 in PyMax, 142 in the web client): the snapshot cannot follow them. */
|
|
1771
|
+
const CHANGES_CHATS = new Set([140, 142]);
|
|
979
1772
|
/** Newest first — the chats a reader most likely came for are read before the cap. */
|
|
980
1773
|
const byRecency = (chats) => chats.toSorted((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? ""));
|
|
981
1774
|
const capped = (chats) => ({
|
|
@@ -1062,4 +1855,46 @@ const asCliError = (error) => {
|
|
|
1062
1855
|
return new CliError("timeout", message);
|
|
1063
1856
|
return new CliError("network_error", message);
|
|
1064
1857
|
};
|
|
1858
|
+
const ownNames = (profile) => {
|
|
1859
|
+
const contact = record(profile.contact) ?? profile;
|
|
1860
|
+
const names = asArray(contact.names);
|
|
1861
|
+
const own = names.find((entry) => entry.type === "ONEME") ?? names[0];
|
|
1862
|
+
return {
|
|
1863
|
+
...(typeof own?.firstName === "string" && own.firstName !== "" ? { firstName: own.firstName } : {}),
|
|
1864
|
+
...(typeof own?.lastName === "string" ? { lastName: own.lastName } : {}),
|
|
1865
|
+
};
|
|
1866
|
+
};
|
|
1867
|
+
/** `+` and digits, as the lookup was measured. ⚠ The refusal never repeats what was typed. */
|
|
1868
|
+
export const wirePhone = (typed) => {
|
|
1869
|
+
const compact = typed.replace(/[\s()-]/g, "");
|
|
1870
|
+
const digits = compact.replace(/^\+/, "");
|
|
1871
|
+
if (!/^\d{7,15}$/.test(digits)) {
|
|
1872
|
+
throw new CliError("validation_error", "a phone number is 7 to 15 digits, with an optional + and the country code");
|
|
1873
|
+
}
|
|
1874
|
+
// A Russian number written the domestic way would otherwise go out as +8…, somebody else's.
|
|
1875
|
+
if (!compact.startsWith("+") && /^8\d{10}$/.test(digits)) {
|
|
1876
|
+
throw new CliError("validation_error", "write a number that starts with 8 with its country code instead: +7…");
|
|
1877
|
+
}
|
|
1878
|
+
return `+${digits}`;
|
|
1879
|
+
};
|
|
1880
|
+
/** 21 characters were refused as too long and 15 were taken (measured 2026-09-24); MAX draws the line. */
|
|
1881
|
+
const folderTitle = (title) => {
|
|
1882
|
+
const trimmed = title.trim();
|
|
1883
|
+
if (trimmed === "")
|
|
1884
|
+
throw new CliError("validation_error", "a folder needs a title");
|
|
1885
|
+
return trimmed;
|
|
1886
|
+
};
|
|
1887
|
+
const pickFolder = (reference, folders) => {
|
|
1888
|
+
const wanted = reference.trim();
|
|
1889
|
+
const byId = folders.find((folder) => folder.id === wanted);
|
|
1890
|
+
if (byId)
|
|
1891
|
+
return byId;
|
|
1892
|
+
const named = folders.filter((folder) => typeof folder.title === "string" && folder.title === wanted);
|
|
1893
|
+
if (named.length === 1 && named[0])
|
|
1894
|
+
return named[0];
|
|
1895
|
+
if (named.length > 1) {
|
|
1896
|
+
throw new CliError("validation_error", `${named.length} folders are called "${wanted}" — name one by id (\`max chats folders list\`)`);
|
|
1897
|
+
}
|
|
1898
|
+
throw new CliError("not_found", `no folder "${wanted}" — \`max chats folders list\` shows them`);
|
|
1899
|
+
};
|
|
1065
1900
|
//# sourceMappingURL=client.js.map
|