@leemour/max-cli 0.15.0 → 0.17.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 (249) hide show
  1. package/README.md +56 -37
  2. package/dist/bot/client.d.ts +6 -0
  3. package/dist/bot/client.d.ts.map +1 -1
  4. package/dist/bot/client.js +17 -3
  5. package/dist/bot/client.js.map +1 -1
  6. package/dist/bot/input.d.ts.map +1 -1
  7. package/dist/bot/input.js +7 -4
  8. package/dist/bot/input.js.map +1 -1
  9. package/dist/bot/joins.d.ts +26 -0
  10. package/dist/bot/joins.d.ts.map +1 -0
  11. package/dist/bot/joins.js +56 -0
  12. package/dist/bot/joins.js.map +1 -0
  13. package/dist/bot/keep.d.ts +2 -2
  14. package/dist/bot/keep.d.ts.map +1 -1
  15. package/dist/bot/keep.js +3 -2
  16. package/dist/bot/keep.js.map +1 -1
  17. package/dist/bot/map.d.ts +5 -2
  18. package/dist/bot/map.d.ts.map +1 -1
  19. package/dist/bot/map.js +7 -1
  20. package/dist/bot/map.js.map +1 -1
  21. package/dist/bot/transport.d.ts +4 -0
  22. package/dist/bot/transport.d.ts.map +1 -1
  23. package/dist/bot/transport.js +43 -3
  24. package/dist/bot/transport.js.map +1 -1
  25. package/dist/bot-mcp/instructions.d.ts +9 -0
  26. package/dist/bot-mcp/instructions.d.ts.map +1 -0
  27. package/dist/bot-mcp/instructions.js +22 -0
  28. package/dist/bot-mcp/instructions.js.map +1 -0
  29. package/dist/bot-mcp/server.d.ts +21 -0
  30. package/dist/bot-mcp/server.d.ts.map +1 -0
  31. package/dist/bot-mcp/server.js +213 -0
  32. package/dist/bot-mcp/server.js.map +1 -0
  33. package/dist/bot-mcp/tools.d.ts +27 -0
  34. package/dist/bot-mcp/tools.d.ts.map +1 -0
  35. package/dist/bot-mcp/tools.js +280 -0
  36. package/dist/bot-mcp/tools.js.map +1 -0
  37. package/dist/client.d.ts +43 -10
  38. package/dist/client.d.ts.map +1 -1
  39. package/dist/client.js +207 -31
  40. package/dist/client.js.map +1 -1
  41. package/dist/commands/account.d.ts.map +1 -1
  42. package/dist/commands/account.js +7 -5
  43. package/dist/commands/account.js.map +1 -1
  44. package/dist/commands/backup.js +1 -1
  45. package/dist/commands/backup.js.map +1 -1
  46. package/dist/commands/bot-check.d.ts +11 -0
  47. package/dist/commands/bot-check.d.ts.map +1 -0
  48. package/dist/commands/bot-check.js +206 -0
  49. package/dist/commands/bot-check.js.map +1 -0
  50. package/dist/commands/bot-comments.d.ts.map +1 -1
  51. package/dist/commands/bot-comments.js +2 -1
  52. package/dist/commands/bot-comments.js.map +1 -1
  53. package/dist/commands/bot-context.d.ts +7 -0
  54. package/dist/commands/bot-context.d.ts.map +1 -1
  55. package/dist/commands/bot-context.js +32 -2
  56. package/dist/commands/bot-context.js.map +1 -1
  57. package/dist/commands/bot-mcp.d.ts +3 -0
  58. package/dist/commands/bot-mcp.d.ts.map +1 -0
  59. package/dist/commands/bot-mcp.js +69 -0
  60. package/dist/commands/bot-mcp.js.map +1 -0
  61. package/dist/commands/bot-members.d.ts.map +1 -1
  62. package/dist/commands/bot-members.js +7 -4
  63. package/dist/commands/bot-members.js.map +1 -1
  64. package/dist/commands/bot-people.d.ts +10 -0
  65. package/dist/commands/bot-people.d.ts.map +1 -0
  66. package/dist/commands/bot-people.js +156 -0
  67. package/dist/commands/bot-people.js.map +1 -0
  68. package/dist/commands/bot-reads.d.ts.map +1 -1
  69. package/dist/commands/bot-reads.js +25 -14
  70. package/dist/commands/bot-reads.js.map +1 -1
  71. package/dist/commands/bot-sends.d.ts.map +1 -1
  72. package/dist/commands/bot-sends.js +6 -5
  73. package/dist/commands/bot-sends.js.map +1 -1
  74. package/dist/commands/bot-setup.d.ts.map +1 -1
  75. package/dist/commands/bot-setup.js +5 -4
  76. package/dist/commands/bot-setup.js.map +1 -1
  77. package/dist/commands/bot-updates.d.ts.map +1 -1
  78. package/dist/commands/bot-updates.js +16 -2
  79. package/dist/commands/bot-updates.js.map +1 -1
  80. package/dist/commands/bot.d.ts.map +1 -1
  81. package/dist/commands/bot.js +13 -4
  82. package/dist/commands/bot.js.map +1 -1
  83. package/dist/commands/chats.d.ts.map +1 -1
  84. package/dist/commands/chats.js +15 -30
  85. package/dist/commands/chats.js.map +1 -1
  86. package/dist/commands/check.d.ts.map +1 -1
  87. package/dist/commands/check.js +7 -6
  88. package/dist/commands/check.js.map +1 -1
  89. package/dist/commands/complete.d.ts.map +1 -1
  90. package/dist/commands/complete.js +27 -3
  91. package/dist/commands/complete.js.map +1 -1
  92. package/dist/commands/config.d.ts.map +1 -1
  93. package/dist/commands/config.js +29 -27
  94. package/dist/commands/config.js.map +1 -1
  95. package/dist/commands/contacts.d.ts.map +1 -1
  96. package/dist/commands/contacts.js +21 -0
  97. package/dist/commands/contacts.js.map +1 -1
  98. package/dist/commands/doctor.d.ts.map +1 -1
  99. package/dist/commands/doctor.js +75 -21
  100. package/dist/commands/doctor.js.map +1 -1
  101. package/dist/commands/export.js +1 -1
  102. package/dist/commands/export.js.map +1 -1
  103. package/dist/commands/folders.d.ts.map +1 -1
  104. package/dist/commands/folders.js +3 -2
  105. package/dist/commands/folders.js.map +1 -1
  106. package/dist/commands/inbox.d.ts.map +1 -1
  107. package/dist/commands/inbox.js +1 -1
  108. package/dist/commands/inbox.js.map +1 -1
  109. package/dist/commands/messages.d.ts.map +1 -1
  110. package/dist/commands/messages.js +9 -11
  111. package/dist/commands/messages.js.map +1 -1
  112. package/dist/commands/models.d.ts.map +1 -1
  113. package/dist/commands/models.js +2 -1
  114. package/dist/commands/models.js.map +1 -1
  115. package/dist/commands/paging.d.ts +17 -1
  116. package/dist/commands/paging.d.ts.map +1 -1
  117. package/dist/commands/paging.js +22 -2
  118. package/dist/commands/paging.js.map +1 -1
  119. package/dist/commands/polls.d.ts +3 -0
  120. package/dist/commands/polls.d.ts.map +1 -0
  121. package/dist/commands/polls.js +103 -0
  122. package/dist/commands/polls.js.map +1 -0
  123. package/dist/commands/recipients.d.ts.map +1 -1
  124. package/dist/commands/recipients.js +3 -2
  125. package/dist/commands/recipients.js.map +1 -1
  126. package/dist/commands/review.d.ts.map +1 -1
  127. package/dist/commands/review.js +1 -1
  128. package/dist/commands/review.js.map +1 -1
  129. package/dist/commands/runs.d.ts.map +1 -1
  130. package/dist/commands/runs.js +4 -3
  131. package/dist/commands/runs.js.map +1 -1
  132. package/dist/commands/sends.d.ts.map +1 -1
  133. package/dist/commands/sends.js +3 -2
  134. package/dist/commands/sends.js.map +1 -1
  135. package/dist/config.d.ts +78 -7
  136. package/dist/config.d.ts.map +1 -1
  137. package/dist/config.js +143 -72
  138. package/dist/config.js.map +1 -1
  139. package/dist/diagnose.d.ts +47 -1
  140. package/dist/diagnose.d.ts.map +1 -1
  141. package/dist/diagnose.js +56 -5
  142. package/dist/diagnose.js.map +1 -1
  143. package/dist/domain/map.d.ts +9 -1
  144. package/dist/domain/map.d.ts.map +1 -1
  145. package/dist/domain/map.js +57 -0
  146. package/dist/domain/map.js.map +1 -1
  147. package/dist/domain/models.d.ts +29 -0
  148. package/dist/domain/models.d.ts.map +1 -1
  149. package/dist/domain/models.js.map +1 -1
  150. package/dist/export.d.ts.map +1 -1
  151. package/dist/export.js +2 -1
  152. package/dist/export.js.map +1 -1
  153. package/dist/generated/client.generated.d.ts +1 -0
  154. package/dist/generated/client.generated.d.ts.map +1 -1
  155. package/dist/generated/client.generated.js +1 -0
  156. package/dist/generated/client.generated.js.map +1 -1
  157. package/dist/generated/opcodes.generated.d.ts +1 -0
  158. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  159. package/dist/generated/opcodes.generated.js +1 -0
  160. package/dist/generated/opcodes.generated.js.map +1 -1
  161. package/dist/generated/operations.generated.d.ts +25 -10
  162. package/dist/generated/operations.generated.d.ts.map +1 -1
  163. package/dist/generated/operations.generated.js +2 -1
  164. package/dist/generated/operations.generated.js.map +1 -1
  165. package/dist/mcp/confirm.d.ts +6 -2
  166. package/dist/mcp/confirm.d.ts.map +1 -1
  167. package/dist/mcp/confirm.js +2 -2
  168. package/dist/mcp/confirm.js.map +1 -1
  169. package/dist/mcp/tools.d.ts.map +1 -1
  170. package/dist/mcp/tools.js +83 -17
  171. package/dist/mcp/tools.js.map +1 -1
  172. package/dist/moderation/check.d.ts +15 -9
  173. package/dist/moderation/check.d.ts.map +1 -1
  174. package/dist/moderation/check.js +14 -45
  175. package/dist/moderation/check.js.map +1 -1
  176. package/dist/moderation/rules.d.ts +57 -7
  177. package/dist/moderation/rules.d.ts.map +1 -1
  178. package/dist/moderation/rules.js +13 -11
  179. package/dist/moderation/rules.js.map +1 -1
  180. package/dist/program.d.ts.map +1 -1
  181. package/dist/program.js +11 -3
  182. package/dist/program.js.map +1 -1
  183. package/dist/rendering/messages.d.ts +6 -1
  184. package/dist/rendering/messages.d.ts.map +1 -1
  185. package/dist/rendering/messages.js +24 -3
  186. package/dist/rendering/messages.js.map +1 -1
  187. package/dist/runs/events.d.ts +5 -2
  188. package/dist/runs/events.d.ts.map +1 -1
  189. package/dist/runs/events.js +4 -1
  190. package/dist/runs/events.js.map +1 -1
  191. package/dist/runs/recording.d.ts +11 -0
  192. package/dist/runs/recording.d.ts.map +1 -1
  193. package/dist/runs/recording.js +28 -12
  194. package/dist/runs/recording.js.map +1 -1
  195. package/dist/sends/guard.d.ts +2 -2
  196. package/dist/sends/guard.d.ts.map +1 -1
  197. package/dist/sends/guard.js +2 -1
  198. package/dist/sends/guard.js.map +1 -1
  199. package/dist/sends/journal.d.ts +2 -2
  200. package/dist/sends/journal.d.ts.map +1 -1
  201. package/dist/sends/journal.js.map +1 -1
  202. package/dist/sends/permissions.d.ts.map +1 -1
  203. package/dist/sends/permissions.js +3 -0
  204. package/dist/sends/permissions.js.map +1 -1
  205. package/dist/server/server-connection.d.ts +8 -1
  206. package/dist/server/server-connection.d.ts.map +1 -1
  207. package/dist/server/server-connection.js +27 -1
  208. package/dist/server/server-connection.js.map +1 -1
  209. package/dist/server/server.d.ts.map +1 -1
  210. package/dist/server/server.js +7 -1
  211. package/dist/server/server.js.map +1 -1
  212. package/dist/server/start.d.ts +1 -1
  213. package/dist/server/start.d.ts.map +1 -1
  214. package/dist/server/start.js +5 -3
  215. package/dist/server/start.js.map +1 -1
  216. package/dist/session/store.d.ts +2 -0
  217. package/dist/session/store.d.ts.map +1 -1
  218. package/dist/session/store.js +5 -1
  219. package/dist/session/store.js.map +1 -1
  220. package/dist/spec/index.d.ts.map +1 -1
  221. package/dist/spec/index.js +2 -1
  222. package/dist/spec/index.js.map +1 -1
  223. package/dist/spec/operations/account.d.ts +3 -0
  224. package/dist/spec/operations/account.d.ts.map +1 -1
  225. package/dist/spec/operations/account.js +5 -1
  226. package/dist/spec/operations/account.js.map +1 -1
  227. package/dist/spec/operations/chats.d.ts +4 -3
  228. package/dist/spec/operations/chats.d.ts.map +1 -1
  229. package/dist/spec/operations/chats.js +12 -7
  230. package/dist/spec/operations/chats.js.map +1 -1
  231. package/dist/spec/operations/contacts.d.ts +8 -3
  232. package/dist/spec/operations/contacts.d.ts.map +1 -1
  233. package/dist/spec/operations/contacts.js +26 -5
  234. package/dist/spec/operations/contacts.js.map +1 -1
  235. package/dist/spec/operations/folders.d.ts.map +1 -1
  236. package/dist/spec/operations/folders.js +1 -0
  237. package/dist/spec/operations/folders.js.map +1 -1
  238. package/dist/spec/operations/messages.d.ts +14 -4
  239. package/dist/spec/operations/messages.d.ts.map +1 -1
  240. package/dist/spec/operations/messages.js +30 -3
  241. package/dist/spec/operations/messages.js.map +1 -1
  242. package/dist/spec/operations/uploads.d.ts +1 -1
  243. package/dist/spec/operations/uploads.d.ts.map +1 -1
  244. package/dist/spec/operations/uploads.js +2 -1
  245. package/dist/spec/operations/uploads.js.map +1 -1
  246. package/dist/version.d.ts +1 -1
  247. package/dist/version.js +1 -1
  248. package/package.json +5 -2
  249. package/skills/max-cli/SKILL.md +19 -9
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@leemour/cli-core";
2
- import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toGroupMember, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
2
+ import { delayMs } from "./config.js";
3
+ import { namesFrom, POLL_CLOSED, pollSettings, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toGroupMember, toMessage, toPoll, toProfile, toReactions, toSession, } from "./domain/map.js";
3
4
  import { heldWindows } from "./export.js";
4
5
  import { wireClient } from "./generated/client.generated.js";
5
6
  import { parseMarkdown } from "./markdown.js";
@@ -71,10 +72,12 @@ export class MaxClient {
71
72
  if (!firstName)
72
73
  throw new CliError("validation_error", "MAX needs a first name, and the profile has none — pass --first-name");
73
74
  const lastName = change.lastName ?? current.lastName;
75
+ const photoToken = change.photo === undefined ? undefined : await this.#profilePhoto(change.photo);
74
76
  const answer = await this.#wire.account.update({
75
77
  firstName,
76
78
  ...(lastName === undefined ? {} : { lastName }),
77
79
  ...(change.description === undefined ? {} : { description: change.description }),
80
+ ...(photoToken === undefined ? {} : { photoToken, avatarType: "USER_AVATAR" }),
78
81
  });
79
82
  return toProfile(record(answer.profile) ?? {});
80
83
  }),
@@ -272,12 +275,13 @@ export class MaxClient {
272
275
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot read a link");
273
276
  const wire = wireLink(link);
274
277
  await this.#connectOnce();
275
- return toGroupCard(record((await this.#wire.chats.linkInfo({ link: wire })).chat) ?? {});
278
+ const answer = await this.#wire.chats.linkInfo({ link: wire }).catch(deadLink(link));
279
+ return toGroupCard(record(answer.chat) ?? {});
276
280
  },
277
281
  join: (link) => {
278
282
  const wire = wireLink(link);
279
283
  return this.#changeChat(null, "join", async () => {
280
- const chat = toGroupCard(record((await this.#wire.chats.join({ link: wire })).chat) ?? {});
284
+ const chat = toGroupCard(record((await this.#wire.chats.join({ link: wire }).catch(deadLink(link))).chat) ?? {});
281
285
  return { chatId: chat.id, result: chat };
282
286
  });
283
287
  },
@@ -320,13 +324,13 @@ export class MaxClient {
320
324
  * Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
321
325
  * whether MAX deduplicates a creation by `cid` is not measured.
322
326
  */
323
- create: async (title, people = []) => {
327
+ create: async (title, people = [], { channel = false } = {}) => {
324
328
  const userIds = await this.#personIds(people);
325
329
  return this.#changeChat(null, "create", async () => {
326
330
  const answer = await this.#wire.messages.send({
327
331
  message: {
328
332
  cid: this.#nextCid(),
329
- attaches: [{ _type: "CONTROL", event: "new", chatType: "CHAT", title, userIds }],
333
+ attaches: [{ _type: "CONTROL", event: "new", chatType: channel ? "CHANNEL" : "CHAT", title, userIds }],
330
334
  },
331
335
  notify: true,
332
336
  });
@@ -384,23 +388,6 @@ export class MaxClient {
384
388
  }),
385
389
  remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
386
390
  },
387
- requests: {
388
- /** Each with the age of their account, read like a member's — the answer's `{contact}` is the same shape. */
389
- list: async (reference) => {
390
- if (this.#offline)
391
- throw new CliError("validation_error", "`--offline` reads what was recorded; join requests never are");
392
- const chatId = await this.chats.resolve(reference);
393
- await this.#connectOnce();
394
- const answer = await this.#wire.chats.members({ chatId, type: "JOIN_REQUEST", count: JOIN_REQUESTS });
395
- return asArray(answer.members).map(toGroupMember);
396
- },
397
- accept: (reference, people) => this.#updateMembers(reference, people, "requests.accept", {
398
- operation: "add",
399
- type: "JOIN_REQUEST",
400
- showHistory: true,
401
- }),
402
- decline: (reference, people) => this.#updateMembers(reference, people, "requests.decline", { operation: "remove", type: "JOIN_REQUEST" }),
403
- },
404
391
  update: async (reference, { title, description }) => {
405
392
  if (title === undefined && description === undefined) {
406
393
  throw new CliError("validation_error", "nothing to change — give --title, --description or both");
@@ -501,8 +488,13 @@ export class MaxClient {
501
488
  throw new CliError("not_found", "MAX has nobody under that number");
502
489
  return this.#remember(toContact(contact));
503
490
  },
504
- add: (reference) => this.#contactAction("contact-add", "ADD", reference),
505
- remove: (reference) => this.#contactAction("contact-remove", "REMOVE", reference),
491
+ add: (reference) => this.#contactAction("contact-add", { action: "ADD" }, reference),
492
+ remove: (reference) => this.#contactAction("contact-remove", { action: "REMOVE" }, reference),
493
+ /** They can no longer write to you. Works for somebody who is not a contact too (measured). */
494
+ block: (reference) => this.#contactAction("contact-block", { action: "BLOCK" }, reference),
495
+ unblock: (reference) => this.#contactAction("contact-unblock", { action: "UNBLOCK" }, reference),
496
+ /** A name of your own for them, kept beside the one they chose; they do not see it. */
497
+ rename: (reference, firstName, lastName) => this.#contactAction("contact-rename", { action: "UPDATE", firstName, ...(lastName === undefined ? {} : { lastName }) }, reference),
506
498
  /** Uploads phone numbers to MAX — other people's — under the names they were saved with. */
507
499
  import: (entries) => this.#change("contact-import", async () => {
508
500
  if (entries.length === 0)
@@ -769,9 +761,14 @@ export class MaxClient {
769
761
  */
770
762
  moment: (reference, flag = "--before") => {
771
763
  const wanted = reference.trim();
772
- const time = /^\d+$/.test(wanted) ? timeOfMessageId(wanted) : Date.parse(wanted);
764
+ const ago = delayMs(wanted);
765
+ const time = /^\d+$/.test(wanted)
766
+ ? timeOfMessageId(wanted)
767
+ : ago === undefined
768
+ ? Date.parse(wanted)
769
+ : Date.now() - ago;
773
770
  if (time === undefined || Number.isNaN(time)) {
774
- throw new CliError("validation_error", `${flag} takes a message id or an ISO 8601 time, not "${wanted}"`);
771
+ throw new CliError("validation_error", `${flag} takes a message id, an ISO 8601 time or 30m, 2h, 1d ago — not "${wanted}"`);
775
772
  }
776
773
  return time;
777
774
  },
@@ -1002,6 +999,160 @@ export class MaxClient {
1002
999
  }
1003
1000
  },
1004
1001
  };
1002
+ /**
1003
+ * **Polls: vote, take a vote back, close one's own, create one.** Every one of them is a write
1004
+ * somebody else can see, so it passes the send guard and the journal under the kind of what it is:
1005
+ * a vote is a `reaction`, closing is an `edit` of the owner's message, a new poll is a `message`.
1006
+ * A vote is never retried — nothing measured makes a blind repeat safe.
1007
+ *
1008
+ * The shapes were measured in Saved messages on 2026-09-27 (`pnpm probe:polls`, `FIND-247`). The
1009
+ * checks before sending are the web client's, so what it would refuse never reaches MAX.
1010
+ */
1011
+ polls = {
1012
+ vote: async (chatId, messageId, answerIds) => {
1013
+ if (this.#offline)
1014
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot vote");
1015
+ this.#guard({ chatId, kind: "reaction" }, messageId);
1016
+ try {
1017
+ await this.#connectOnce();
1018
+ const { attach, poll } = await this.#poll(chatId, messageId);
1019
+ const voted = poll.answers.some((answer) => answer.mine);
1020
+ if (answerIds.length === 0 && !voted) {
1021
+ throw new CliError("validation_error", `you have not voted in the poll of message ${messageId}`);
1022
+ }
1023
+ if (answerIds.length === 0 && !poll.revote) {
1024
+ throw new CliError("validation_error", `the poll of message ${messageId} does not let a vote be taken back`);
1025
+ }
1026
+ if (answerIds.length > 0 && voted && !poll.revote) {
1027
+ throw new CliError("validation_error", `you have voted in the poll of message ${messageId}, and it does not let a vote change`);
1028
+ }
1029
+ if (answerIds.length > 1 && !poll.multiple) {
1030
+ throw new CliError("validation_error", `the poll of message ${messageId} takes one answer, not ${answerIds.length}`);
1031
+ }
1032
+ const unknown = answerIds.find((id) => !poll.answers.some((answer) => answer.id === id));
1033
+ if (unknown !== undefined) {
1034
+ throw new CliError("validation_error", `the poll of message ${messageId} has no answer ${unknown} — its answers are ${poll.answers.map((answer) => answer.id).join(", ")}`);
1035
+ }
1036
+ const answer = await this.#wire.messages.pollVote({
1037
+ chatId,
1038
+ messageId,
1039
+ pollId: poll.id,
1040
+ answersIds: answerIds.map(Number),
1041
+ });
1042
+ this.#cache?.messages.invalidate(chatId);
1043
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
1044
+ const state = record(answer.state);
1045
+ return { chatId, messageId, poll: (state && toPoll({ ...attach, state })) ?? poll };
1046
+ }
1047
+ catch (error) {
1048
+ this.#sends?.record({
1049
+ chatId,
1050
+ kind: "reaction",
1051
+ outcome: "failed",
1052
+ messageId,
1053
+ errorCode: asCliError(error).code,
1054
+ });
1055
+ throw error;
1056
+ }
1057
+ },
1058
+ /** Closes the owner's own poll: the web client edits the message with the `closed` bit raised. */
1059
+ close: async (chatId, messageId) => {
1060
+ if (this.#offline)
1061
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot close a poll");
1062
+ this.#guard({ chatId, kind: "edit" }, messageId);
1063
+ try {
1064
+ await this.#connectOnce();
1065
+ const { attach, poll, outgoing } = await this.#poll(chatId, messageId);
1066
+ if (!outgoing)
1067
+ throw new CliError("validation_error", `the poll of message ${messageId} is not yours to close`);
1068
+ // What the web client sends back: the answers' text only, no ids and no votes.
1069
+ const closed = {
1070
+ _type: "POLL",
1071
+ pollId: attach.pollId,
1072
+ title: attach.title,
1073
+ answers: asArray(attach.answers).map((each) => ({ text: each.text })),
1074
+ settings: (typeof attach.settings === "number" ? attach.settings : 0) | POLL_CLOSED,
1075
+ };
1076
+ const answer = await this.#wire.messages.edit({ chatId, messageId, attachments: [closed] });
1077
+ this.#cache?.messages.invalidate(chatId);
1078
+ this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId });
1079
+ const after = asArray(record(answer.message)?.attaches).find((each) => each._type === "POLL");
1080
+ return { chatId, messageId, poll: (after && toPoll(after)) ?? { ...poll, closed: true } };
1081
+ }
1082
+ catch (error) {
1083
+ this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: asCliError(error).code });
1084
+ throw error;
1085
+ }
1086
+ },
1087
+ /**
1088
+ * A new message whose one attachment is the poll — so it goes through `#deliver`, with its `cid`
1089
+ * and its one retry, like any message.
1090
+ */
1091
+ create: async (chatId, question, answers, options = {}) => {
1092
+ if (this.#offline)
1093
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot create a poll");
1094
+ if (question.trim() === "")
1095
+ throw new CliError("validation_error", "a poll needs a question");
1096
+ if (answers.length < 2 || answers.some((answer) => answer.trim() === "")) {
1097
+ throw new CliError("validation_error", "a poll needs at least two answers, none of them empty");
1098
+ }
1099
+ try {
1100
+ this.#sends?.check({ chatId, kind: "message", ...(options.cid === undefined ? {} : { cid: options.cid }) });
1101
+ }
1102
+ catch (error) {
1103
+ this.#sends?.record({ chatId, kind: "message", outcome: "refused", errorCode: asCliError(error).code });
1104
+ throw error;
1105
+ }
1106
+ const cid = options.cid ?? this.#nextCid();
1107
+ const attach = {
1108
+ _type: "POLL",
1109
+ title: question,
1110
+ answers: answers.map((text) => ({ text })),
1111
+ settings: pollSettings(options),
1112
+ };
1113
+ try {
1114
+ const sent = await this.#deliver(chatId, "", cid, {
1115
+ ...options,
1116
+ attaches: [attach],
1117
+ repeat: `max polls create ${chatId} …`,
1118
+ });
1119
+ this.#sends?.record({
1120
+ chatId,
1121
+ kind: "message",
1122
+ outcome: "sent",
1123
+ messageId: sent.id,
1124
+ cid,
1125
+ length: question.length,
1126
+ });
1127
+ return sent;
1128
+ }
1129
+ catch (error) {
1130
+ const failure = asCliError(error);
1131
+ this.#sends?.record({
1132
+ chatId,
1133
+ kind: "message",
1134
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
1135
+ cid,
1136
+ errorCode: failure.code,
1137
+ });
1138
+ throw error;
1139
+ }
1140
+ },
1141
+ };
1142
+ /** The poll of one message, whole as MAX sends it and read, or a refusal that says why there is none. */
1143
+ async #poll(chatId, messageId) {
1144
+ const raw = await this.#rawMessage(chatId, messageId);
1145
+ const attach = asArray(raw.attaches).find((each) => each._type === "POLL");
1146
+ if (!attach)
1147
+ throw new CliError("validation_error", `message ${messageId} carries no poll`);
1148
+ const poll = toPoll(attach);
1149
+ if (!poll)
1150
+ throw new CliError("validation_error", `the poll of message ${messageId} is a newer kind this version cannot read`);
1151
+ if (poll.closed)
1152
+ throw new CliError("validation_error", `the poll of message ${messageId} is closed`);
1153
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
1154
+ return { attach, poll, outgoing: toMessage(raw, chatId, lookup).outgoing === true };
1155
+ }
1005
1156
  folders = {
1006
1157
  list: async () => (await this.#folders()).map(toFolder),
1007
1158
  create: (title, chats = []) => this.#change("folder-create", async () => {
@@ -1218,7 +1369,7 @@ export class MaxClient {
1218
1369
  async #deliver(chatId, text, cid, options) {
1219
1370
  await this.#connectOnce();
1220
1371
  const session = this.#session();
1221
- const attaches = [];
1372
+ const attaches = [...(options.attaches ?? [])];
1222
1373
  for (const file of options.files ?? [])
1223
1374
  attaches.push(await this.#upload(file));
1224
1375
  const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
@@ -1453,6 +1604,10 @@ export class MaxClient {
1453
1604
  `the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
1454
1605
  `\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
1455
1606
  }
1607
+ if (this.#store.isBot()) {
1608
+ throw new CliError("authentication_error", `profile "${profile}" is a bot — its commands are \`max ${asFirstWord(profile)}bot …\`; ` +
1609
+ `\`max ${asFirstWord(profile)}session start\` would add a personal account to it`);
1610
+ }
1456
1611
  // The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
1457
1612
  // in under is the ordinary shape of this failure now that the first word is the profile.
1458
1613
  throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}session start\``);
@@ -1856,6 +2011,15 @@ export class MaxClient {
1856
2011
  * Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
1857
2012
  * retried: a failure here happens before `MSG_SEND`, so nothing was sent.
1858
2013
  */
2014
+ async #profilePhoto(path) {
2015
+ if (!isImage(path))
2016
+ throw new CliError("validation_error", `${path} is not an image MAX takes as a photo`);
2017
+ const bytes = await readUpload(path);
2018
+ const { url } = await this.#wire.uploads.photo({ count: 1, type: 0, uploaderType: 0, profile: true });
2019
+ if (typeof url !== "string")
2020
+ throw new CliError("provider_error", "MAX gave no address to upload the photo to");
2021
+ return uploadPhoto(url, path, bytes);
2022
+ }
1859
2023
  async #upload({ path, bytes, kind, voice }) {
1860
2024
  const request = { count: 1, type: 0, uploaderType: 0, profile: false };
1861
2025
  if (kind === "photo") {
@@ -2081,7 +2245,7 @@ export class MaxClient {
2081
2245
  throw new CliError("validation_error", "without a local store a person is named by id — `max contacts lookup` finds one");
2082
2246
  }
2083
2247
  const id = isId(reference) ? reference.trim() : pickPerson(reference, cache).id;
2084
- const answer = await this.#wire.contacts.update({ contactId: id, action: wire });
2248
+ const answer = await this.#wire.contacts.update({ contactId: id, ...wire });
2085
2249
  const contact = record(answer.contact);
2086
2250
  if (contact)
2087
2251
  return this.#remember(toContact(contact));
@@ -2112,20 +2276,25 @@ export class MaxClient {
2112
2276
  return this.#login;
2113
2277
  }
2114
2278
  }
2115
- /** PyMax `AdminPermission`; the sum is what MAX takes. */
2279
+ /**
2280
+ * PyMax `AdminPermission`; the sum is what MAX takes. `read` and `link` measured 2026-09-27 by
2281
+ * switching them in the app for a bot and reading its `permissions` back (`MAX-61`): «Read messages»
2282
+ * sets 1 and 32 together — the Bot API calls them `read_all_messages` and `write` — and «Update chat
2283
+ * link» 128. A bot without `read` sees no message of the group.
2284
+ */
2116
2285
  export const ADMIN_RIGHTS = {
2286
+ read: 1 | 32,
2117
2287
  members: 2,
2118
2288
  admins: 4,
2119
2289
  info: 8,
2120
2290
  pin: 16,
2291
+ link: 128,
2121
2292
  post: 256,
2122
2293
  edit: 512,
2123
2294
  delete: 1024,
2124
2295
  };
2125
- /** PyMax asks for this many; paging join requests is not known. */
2126
2296
  /** The most chats `max serve` holds from pushes before it logs in again instead. */
2127
2297
  const LIVE_CHATS = 10_000;
2128
- const JOIN_REQUESTS = 100;
2129
2298
  /** PyMax's page, the one measured. */
2130
2299
  const MEMBERS_PAGE = 50;
2131
2300
  const MEMBERS_READ = 5000;
@@ -2316,6 +2485,13 @@ const asRefusal = (error) => {
2316
2485
  }
2317
2486
  return new CliError("provider_error", error.message, { operation: String(error.opcode) });
2318
2487
  };
2488
+ /** A link that was reset, or never led anywhere, is `not.found` from MAX (measured 2026-09-28). */
2489
+ const deadLink = (link) => (error) => {
2490
+ const failure = asCliError(error);
2491
+ if (failure.details.maxError !== "not.found")
2492
+ throw failure;
2493
+ throw new CliError("not_found", `${link.trim()} leads nowhere — the link was reset, or never worked`);
2494
+ };
2319
2495
  const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
2320
2496
  /** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
2321
2497
  export const refuseWhilePaused = (state) => {