@leemour/max-cli 0.5.0 → 0.7.0

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