@leemour/max-cli 0.14.0 → 0.16.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 (219) hide show
  1. package/README.md +43 -9
  2. package/dist/bot/client.d.ts +7 -1
  3. package/dist/bot/client.d.ts.map +1 -1
  4. package/dist/bot/client.js +19 -5
  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 +25 -0
  14. package/dist/bot/keep.d.ts.map +1 -0
  15. package/dist/bot/keep.js +97 -0
  16. package/dist/bot/keep.js.map +1 -0
  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/registry.d.ts +3 -0
  22. package/dist/bot/registry.d.ts.map +1 -1
  23. package/dist/bot/registry.js +27 -10
  24. package/dist/bot/registry.js.map +1 -1
  25. package/dist/bot/uploads.d.ts +34 -0
  26. package/dist/bot/uploads.d.ts.map +1 -0
  27. package/dist/bot/uploads.js +122 -0
  28. package/dist/bot/uploads.js.map +1 -0
  29. package/dist/bot-mcp/instructions.d.ts +9 -0
  30. package/dist/bot-mcp/instructions.d.ts.map +1 -0
  31. package/dist/bot-mcp/instructions.js +22 -0
  32. package/dist/bot-mcp/instructions.js.map +1 -0
  33. package/dist/bot-mcp/server.d.ts +21 -0
  34. package/dist/bot-mcp/server.d.ts.map +1 -0
  35. package/dist/bot-mcp/server.js +199 -0
  36. package/dist/bot-mcp/server.js.map +1 -0
  37. package/dist/bot-mcp/tools.d.ts +27 -0
  38. package/dist/bot-mcp/tools.d.ts.map +1 -0
  39. package/dist/bot-mcp/tools.js +280 -0
  40. package/dist/bot-mcp/tools.js.map +1 -0
  41. package/dist/client.d.ts +44 -4
  42. package/dist/client.d.ts.map +1 -1
  43. package/dist/client.js +232 -41
  44. package/dist/client.js.map +1 -1
  45. package/dist/commands/bot-check.d.ts +11 -0
  46. package/dist/commands/bot-check.d.ts.map +1 -0
  47. package/dist/commands/bot-check.js +206 -0
  48. package/dist/commands/bot-check.js.map +1 -0
  49. package/dist/commands/bot-comments.d.ts +4 -0
  50. package/dist/commands/bot-comments.d.ts.map +1 -0
  51. package/dist/commands/bot-comments.js +97 -0
  52. package/dist/commands/bot-comments.js.map +1 -0
  53. package/dist/commands/bot-context.d.ts +9 -3
  54. package/dist/commands/bot-context.d.ts.map +1 -1
  55. package/dist/commands/bot-context.js +38 -15
  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 +12 -0
  62. package/dist/commands/bot-members.d.ts.map +1 -0
  63. package/dist/commands/bot-members.js +136 -0
  64. package/dist/commands/bot-members.js.map +1 -0
  65. package/dist/commands/bot-people.d.ts +10 -0
  66. package/dist/commands/bot-people.d.ts.map +1 -0
  67. package/dist/commands/bot-people.js +156 -0
  68. package/dist/commands/bot-people.js.map +1 -0
  69. package/dist/commands/bot-reads.d.ts.map +1 -1
  70. package/dist/commands/bot-reads.js +56 -8
  71. package/dist/commands/bot-reads.js.map +1 -1
  72. package/dist/commands/bot-sends.d.ts +5 -0
  73. package/dist/commands/bot-sends.d.ts.map +1 -1
  74. package/dist/commands/bot-sends.js +56 -11
  75. package/dist/commands/bot-sends.js.map +1 -1
  76. package/dist/commands/bot-setup.d.ts +5 -0
  77. package/dist/commands/bot-setup.d.ts.map +1 -0
  78. package/dist/commands/bot-setup.js +107 -0
  79. package/dist/commands/bot-setup.js.map +1 -0
  80. package/dist/commands/bot-updates.d.ts +3 -0
  81. package/dist/commands/bot-updates.d.ts.map +1 -0
  82. package/dist/commands/bot-updates.js +196 -0
  83. package/dist/commands/bot-updates.js.map +1 -0
  84. package/dist/commands/bot.d.ts.map +1 -1
  85. package/dist/commands/bot.js +21 -1
  86. package/dist/commands/bot.js.map +1 -1
  87. package/dist/commands/chats.d.ts.map +1 -1
  88. package/dist/commands/chats.js +19 -6
  89. package/dist/commands/chats.js.map +1 -1
  90. package/dist/commands/check.d.ts +8 -0
  91. package/dist/commands/check.d.ts.map +1 -0
  92. package/dist/commands/check.js +64 -0
  93. package/dist/commands/check.js.map +1 -0
  94. package/dist/commands/context.d.ts +5 -0
  95. package/dist/commands/context.d.ts.map +1 -1
  96. package/dist/commands/context.js +5 -1
  97. package/dist/commands/context.js.map +1 -1
  98. package/dist/commands/hearing.d.ts +25 -0
  99. package/dist/commands/hearing.d.ts.map +1 -0
  100. package/dist/commands/hearing.js +41 -0
  101. package/dist/commands/hearing.js.map +1 -0
  102. package/dist/commands/inbox.d.ts.map +1 -1
  103. package/dist/commands/inbox.js +19 -3
  104. package/dist/commands/inbox.js.map +1 -1
  105. package/dist/commands/mcp.d.ts +1 -0
  106. package/dist/commands/mcp.d.ts.map +1 -1
  107. package/dist/commands/mcp.js +7 -4
  108. package/dist/commands/mcp.js.map +1 -1
  109. package/dist/commands/messages.d.ts.map +1 -1
  110. package/dist/commands/messages.js +21 -5
  111. package/dist/commands/messages.js.map +1 -1
  112. package/dist/commands/paging.d.ts +3 -1
  113. package/dist/commands/paging.d.ts.map +1 -1
  114. package/dist/commands/paging.js +4 -1
  115. package/dist/commands/paging.js.map +1 -1
  116. package/dist/commands/polls.d.ts +3 -0
  117. package/dist/commands/polls.d.ts.map +1 -0
  118. package/dist/commands/polls.js +103 -0
  119. package/dist/commands/polls.js.map +1 -0
  120. package/dist/commands/review.d.ts.map +1 -1
  121. package/dist/commands/review.js +5 -7
  122. package/dist/commands/review.js.map +1 -1
  123. package/dist/domain/map.d.ts +9 -1
  124. package/dist/domain/map.d.ts.map +1 -1
  125. package/dist/domain/map.js +57 -0
  126. package/dist/domain/map.js.map +1 -1
  127. package/dist/domain/models.d.ts +33 -0
  128. package/dist/domain/models.d.ts.map +1 -1
  129. package/dist/domain/models.js.map +1 -1
  130. package/dist/generated/client.generated.d.ts +2 -0
  131. package/dist/generated/client.generated.d.ts.map +1 -1
  132. package/dist/generated/client.generated.js +2 -0
  133. package/dist/generated/client.generated.js.map +1 -1
  134. package/dist/generated/opcodes.generated.d.ts +2 -0
  135. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  136. package/dist/generated/opcodes.generated.js +2 -0
  137. package/dist/generated/opcodes.generated.js.map +1 -1
  138. package/dist/generated/operations.generated.d.ts +24 -3
  139. package/dist/generated/operations.generated.d.ts.map +1 -1
  140. package/dist/generated/operations.generated.js +4 -2
  141. package/dist/generated/operations.generated.js.map +1 -1
  142. package/dist/mcp/confirm.d.ts +6 -2
  143. package/dist/mcp/confirm.d.ts.map +1 -1
  144. package/dist/mcp/confirm.js +2 -2
  145. package/dist/mcp/confirm.js.map +1 -1
  146. package/dist/mcp/instructions.d.ts +2 -1
  147. package/dist/mcp/instructions.d.ts.map +1 -1
  148. package/dist/mcp/instructions.js +2 -1
  149. package/dist/mcp/instructions.js.map +1 -1
  150. package/dist/mcp/server.d.ts +2 -1
  151. package/dist/mcp/server.d.ts.map +1 -1
  152. package/dist/mcp/server.js +4 -1
  153. package/dist/mcp/server.js.map +1 -1
  154. package/dist/mcp/tools.d.ts +5 -1
  155. package/dist/mcp/tools.d.ts.map +1 -1
  156. package/dist/mcp/tools.js +187 -12
  157. package/dist/mcp/tools.js.map +1 -1
  158. package/dist/moderation/check.d.ts +105 -0
  159. package/dist/moderation/check.d.ts.map +1 -0
  160. package/dist/moderation/check.js +313 -0
  161. package/dist/moderation/check.js.map +1 -0
  162. package/dist/program.d.ts.map +1 -1
  163. package/dist/program.js +2 -0
  164. package/dist/program.js.map +1 -1
  165. package/dist/rendering/messages.d.ts +6 -1
  166. package/dist/rendering/messages.d.ts.map +1 -1
  167. package/dist/rendering/messages.js +24 -3
  168. package/dist/rendering/messages.js.map +1 -1
  169. package/dist/review.d.ts +10 -4
  170. package/dist/review.d.ts.map +1 -1
  171. package/dist/review.js +35 -53
  172. package/dist/review.js.map +1 -1
  173. package/dist/sends/journal.d.ts +1 -1
  174. package/dist/sends/journal.d.ts.map +1 -1
  175. package/dist/server/server-connection.d.ts +8 -1
  176. package/dist/server/server-connection.d.ts.map +1 -1
  177. package/dist/server/server-connection.js +27 -1
  178. package/dist/server/server-connection.js.map +1 -1
  179. package/dist/server/server.d.ts.map +1 -1
  180. package/dist/server/server.js +2 -1
  181. package/dist/server/server.js.map +1 -1
  182. package/dist/server/start.d.ts +1 -1
  183. package/dist/server/start.d.ts.map +1 -1
  184. package/dist/server/start.js +5 -3
  185. package/dist/server/start.js.map +1 -1
  186. package/dist/session/store.d.ts +2 -0
  187. package/dist/session/store.d.ts.map +1 -1
  188. package/dist/session/store.js +2 -0
  189. package/dist/session/store.js.map +1 -1
  190. package/dist/spec/index.d.ts.map +1 -1
  191. package/dist/spec/index.js +4 -2
  192. package/dist/spec/index.js.map +1 -1
  193. package/dist/spec/operations/chats.d.ts.map +1 -1
  194. package/dist/spec/operations/chats.js +3 -1
  195. package/dist/spec/operations/chats.js.map +1 -1
  196. package/dist/spec/operations/messages.d.ts +13 -3
  197. package/dist/spec/operations/messages.d.ts.map +1 -1
  198. package/dist/spec/operations/messages.js +37 -6
  199. package/dist/spec/operations/messages.js.map +1 -1
  200. package/dist/spec/operations/uploads.d.ts +13 -0
  201. package/dist/spec/operations/uploads.d.ts.map +1 -1
  202. package/dist/spec/operations/uploads.js +21 -0
  203. package/dist/spec/operations/uploads.js.map +1 -1
  204. package/dist/transcribe/index.d.ts +36 -1
  205. package/dist/transcribe/index.d.ts.map +1 -1
  206. package/dist/transcribe/index.js +91 -14
  207. package/dist/transcribe/index.js.map +1 -1
  208. package/dist/upload.d.ts +3 -0
  209. package/dist/upload.d.ts.map +1 -1
  210. package/dist/upload.js +13 -7
  211. package/dist/upload.js.map +1 -1
  212. package/dist/version.d.ts +1 -1
  213. package/dist/version.js +1 -1
  214. package/dist/voice.d.ts +9 -0
  215. package/dist/voice.d.ts.map +1 -0
  216. package/dist/voice.js +36 -0
  217. package/dist/voice.js.map +1 -0
  218. package/package.json +6 -2
  219. package/skills/max-cli/SKILL.md +17 -5
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
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 { namesFrom, POLL_CLOSED, pollSettings, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toGroupMember, toMessage, toPoll, toProfile, toReactions, toSession, } from "./domain/map.js";
3
3
  import { heldWindows } from "./export.js";
4
4
  import { wireClient } from "./generated/client.generated.js";
5
5
  import { parseMarkdown } from "./markdown.js";
@@ -14,7 +14,8 @@ import { loginPausedUntil, withLoginRefused, withoutLoginPause, } from "./sessio
14
14
  import { WEB_USER_AGENT } from "./spec/identity.js";
15
15
  import { buildRequest, checkResponse } from "./spec/index.js";
16
16
  import { ASSET_TYPES } from "./spec/operations/assets.js";
17
- import { isImage, readUpload, uploadFile, uploadPhoto } from "./upload.js";
17
+ import { isImage, isVideo, readUpload, uploadFile, uploadMedia, uploadPhoto } from "./upload.js";
18
+ import { voiceOf } from "./voice.js";
18
19
  /**
19
20
  * **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
20
21
  * opcodes and the frames stop here, so a different transport underneath changes this file and
@@ -209,41 +210,18 @@ export class MaxClient {
209
210
  * come from the local copy.
210
211
  */
211
212
  adminIds: async (chatId) => {
212
- await this.#connectOnce();
213
- const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
214
- if (!raw || (raw.owner === undefined && raw.admins === undefined && raw.adminParticipants === undefined)) {
215
- return undefined;
216
- }
217
- const ids = [
218
- asId(raw.owner),
219
- ...(Array.isArray(raw.admins) ? raw.admins.map(asId) : []),
220
- ...Object.keys(record(raw.adminParticipants) ?? {}),
221
- ];
222
- return [...new Set(ids.filter((id) => id !== undefined))];
213
+ const roles = await this.#roles(chatId);
214
+ return roles && [...new Set([...(roles.owner ? [roles.owner] : []), ...roles.admins])];
223
215
  },
224
216
  /**
225
217
  * Who joined, left, was added or removed since a point: the service messages in the chat's
226
218
  * history, read forward from `since` without reactions. At most `EVENTS_READ` messages, the oldest.
227
219
  */
228
- events: async (reference, { since }) => {
220
+ events: async (reference, { since = Date.now() - EVENTS_DAYS * 86_400_000 } = {}) => {
229
221
  if (this.#offline)
230
222
  throw new CliError("validation_error", "`--offline` has no history to read events from");
231
223
  const chatId = await this.chats.resolve(reference);
232
- const read = [];
233
- let from = since;
234
- let more = false;
235
- while (true) {
236
- const page = (await this.#history(chatId, { from, backward: 0, forward: REVIEW_PAGE + 1 }, { reactions: false })).filter((message) => Date.parse(message.timestamp) > from);
237
- read.push(...page);
238
- const last = page.at(-1);
239
- if (page.length < REVIEW_PAGE || !last)
240
- break;
241
- if (read.length >= EVENTS_READ) {
242
- more = true;
243
- break;
244
- }
245
- from = Date.parse(last.timestamp);
246
- }
224
+ const { messages: read, more } = await this.chats.since(chatId, since);
247
225
  const found = read.flatMap((message) => message.attachments
248
226
  .filter((attachment) => attachment.kind === "control" && attachment.event !== undefined)
249
227
  .map((attachment) => ({ message, attachment })));
@@ -270,6 +248,24 @@ export class MaxClient {
270
248
  })),
271
249
  };
272
250
  },
251
+ /**
252
+ * A chat's history after `since`, oldest first and without reactions, at most `EVENTS_READ`
253
+ * messages; `more` when there was more than that.
254
+ */
255
+ since: async (chatId, since) => {
256
+ const read = [];
257
+ let from = since;
258
+ while (true) {
259
+ const page = (await this.#history(chatId, { from, backward: 0, forward: REVIEW_PAGE + 1 }, { reactions: false })).filter((message) => Date.parse(message.timestamp) > from);
260
+ read.push(...page);
261
+ const last = page.at(-1);
262
+ if (page.length < REVIEW_PAGE || !last)
263
+ return { messages: read, more: false };
264
+ if (read.length >= EVENTS_READ)
265
+ return { messages: read, more: true };
266
+ from = Date.parse(last.timestamp);
267
+ }
268
+ },
273
269
  /** What a link leads to, without joining it. */
274
270
  inspect: async (link) => {
275
271
  if (this.#offline)
@@ -367,7 +363,14 @@ export class MaxClient {
367
363
  }
368
364
  marker = next;
369
365
  }
370
- return { chatId, members: [...members.values()], complete };
366
+ const roles = await this.#roles(chatId);
367
+ const role = (id) => (id === roles?.owner ? "owner" : roles?.admins.has(id) ? "admin" : "member");
368
+ return {
369
+ chatId,
370
+ members: [...members.values()].map((member) => (roles ? { ...member, role: role(member.id) } : member)),
371
+ complete,
372
+ rolesKnown: roles !== undefined,
373
+ };
371
374
  },
372
375
  /** No history unless asked (`NEED-272`): what was said before somebody joined is not theirs by default. */
373
376
  add: (reference, people, { history = false } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
@@ -382,13 +385,14 @@ export class MaxClient {
382
385
  remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
383
386
  },
384
387
  requests: {
388
+ /** Each with the age of their account, read like a member's — the answer's `{contact}` is the same shape. */
385
389
  list: async (reference) => {
386
390
  if (this.#offline)
387
391
  throw new CliError("validation_error", "`--offline` reads what was recorded; join requests never are");
388
392
  const chatId = await this.chats.resolve(reference);
389
393
  await this.#connectOnce();
390
394
  const answer = await this.#wire.chats.members({ chatId, type: "JOIN_REQUEST", count: JOIN_REQUESTS });
391
- return asArray(answer.members).map((member) => toContact(record(member.contact) ?? {}));
395
+ return asArray(answer.members).map(toGroupMember);
392
396
  },
393
397
  accept: (reference, people) => this.#updateMembers(reference, people, "requests.accept", {
394
398
  operation: "add",
@@ -791,6 +795,10 @@ export class MaxClient {
791
795
  send: async (chatId, text, options = {}) => {
792
796
  if (this.#offline)
793
797
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot send");
798
+ if (options.voice !== undefined && (text !== "" || (options.files ?? []).length > 0)) {
799
+ // MAX kept a caption beside a voice message (measured 2026-09-27), but web.max.ru never sends one; §34.
800
+ throw new CliError("validation_error", "a voice message goes alone — no text and no --file beside it");
801
+ }
794
802
  if (options.at !== undefined && options.notify === false) {
795
803
  // The web client always sends a scheduled message with `notify: true`; §34.
796
804
  throw new CliError("validation_error", "a scheduled message cannot be silent — MAX's own client never sends one");
@@ -802,11 +810,16 @@ export class MaxClient {
802
810
  const files = await Promise.all((options.files ?? []).map(async (path) => ({
803
811
  path,
804
812
  bytes: await readUpload(path, { anyFile: options.anyFile === true }),
805
- photo: isImage(path),
813
+ kind: isImage(path) ? "photo" : isVideo(path) && options.asFile !== true ? "video" : "file",
806
814
  })));
815
+ if (options.voice !== undefined) {
816
+ const bytes = await readUpload(options.voice, { anyFile: options.anyFile === true });
817
+ files.push({ path: options.voice, bytes, kind: "voice", voice: await voiceOf(options.voice, bytes) });
818
+ }
807
819
  // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
808
- if (files.some((file) => !file.photo) && files.length > 1) {
809
- throw new CliError("validation_error", "a file goes in a message of its own — photos can share one; send them apart");
820
+ // A video is kept alone the same way; a mixed message was never measured.
821
+ if (files.some((file) => file.kind !== "photo") && files.length > 1) {
822
+ throw new CliError("validation_error", "a file or a video goes in a message of its own — photos can share one; send them apart");
810
823
  }
811
824
  // Before connecting: a refused send never opens a socket when the chat was given as an id.
812
825
  try {
@@ -822,10 +835,7 @@ export class MaxClient {
822
835
  throw error;
823
836
  }
824
837
  const cid = options.cid ?? this.#nextCid();
825
- const attachments = files.map(({ bytes, photo }) => ({
826
- kind: photo ? "photo" : "file",
827
- bytes: bytes.length,
828
- }));
838
+ const attachments = files.map(({ bytes, kind }) => ({ kind, bytes: bytes.length }));
829
839
  const summary = {
830
840
  ...(attachments.length > 0 ? { attachments } : {}),
831
841
  ...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
@@ -992,6 +1002,160 @@ export class MaxClient {
992
1002
  }
993
1003
  },
994
1004
  };
1005
+ /**
1006
+ * **Polls: vote, take a vote back, close one's own, create one.** Every one of them is a write
1007
+ * somebody else can see, so it passes the send guard and the journal under the kind of what it is:
1008
+ * a vote is a `reaction`, closing is an `edit` of the owner's message, a new poll is a `message`.
1009
+ * A vote is never retried — nothing measured makes a blind repeat safe.
1010
+ *
1011
+ * The shapes were measured in Saved messages on 2026-09-27 (`pnpm probe:polls`, `FIND-247`). The
1012
+ * checks before sending are the web client's, so what it would refuse never reaches MAX.
1013
+ */
1014
+ polls = {
1015
+ vote: async (chatId, messageId, answerIds) => {
1016
+ if (this.#offline)
1017
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot vote");
1018
+ this.#guard({ chatId, kind: "reaction" }, messageId);
1019
+ try {
1020
+ await this.#connectOnce();
1021
+ const { attach, poll } = await this.#poll(chatId, messageId);
1022
+ const voted = poll.answers.some((answer) => answer.mine);
1023
+ if (answerIds.length === 0 && !voted) {
1024
+ throw new CliError("validation_error", `you have not voted in the poll of message ${messageId}`);
1025
+ }
1026
+ if (answerIds.length === 0 && !poll.revote) {
1027
+ throw new CliError("validation_error", `the poll of message ${messageId} does not let a vote be taken back`);
1028
+ }
1029
+ if (answerIds.length > 0 && voted && !poll.revote) {
1030
+ throw new CliError("validation_error", `you have voted in the poll of message ${messageId}, and it does not let a vote change`);
1031
+ }
1032
+ if (answerIds.length > 1 && !poll.multiple) {
1033
+ throw new CliError("validation_error", `the poll of message ${messageId} takes one answer, not ${answerIds.length}`);
1034
+ }
1035
+ const unknown = answerIds.find((id) => !poll.answers.some((answer) => answer.id === id));
1036
+ if (unknown !== undefined) {
1037
+ throw new CliError("validation_error", `the poll of message ${messageId} has no answer ${unknown} — its answers are ${poll.answers.map((answer) => answer.id).join(", ")}`);
1038
+ }
1039
+ const answer = await this.#wire.messages.pollVote({
1040
+ chatId,
1041
+ messageId,
1042
+ pollId: poll.id,
1043
+ answersIds: answerIds.map(Number),
1044
+ });
1045
+ this.#cache?.messages.invalidate(chatId);
1046
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
1047
+ const state = record(answer.state);
1048
+ return { chatId, messageId, poll: (state && toPoll({ ...attach, state })) ?? poll };
1049
+ }
1050
+ catch (error) {
1051
+ this.#sends?.record({
1052
+ chatId,
1053
+ kind: "reaction",
1054
+ outcome: "failed",
1055
+ messageId,
1056
+ errorCode: asCliError(error).code,
1057
+ });
1058
+ throw error;
1059
+ }
1060
+ },
1061
+ /** Closes the owner's own poll: the web client edits the message with the `closed` bit raised. */
1062
+ close: async (chatId, messageId) => {
1063
+ if (this.#offline)
1064
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot close a poll");
1065
+ this.#guard({ chatId, kind: "edit" }, messageId);
1066
+ try {
1067
+ await this.#connectOnce();
1068
+ const { attach, poll, outgoing } = await this.#poll(chatId, messageId);
1069
+ if (!outgoing)
1070
+ throw new CliError("validation_error", `the poll of message ${messageId} is not yours to close`);
1071
+ // What the web client sends back: the answers' text only, no ids and no votes.
1072
+ const closed = {
1073
+ _type: "POLL",
1074
+ pollId: attach.pollId,
1075
+ title: attach.title,
1076
+ answers: asArray(attach.answers).map((each) => ({ text: each.text })),
1077
+ settings: (typeof attach.settings === "number" ? attach.settings : 0) | POLL_CLOSED,
1078
+ };
1079
+ const answer = await this.#wire.messages.edit({ chatId, messageId, attachments: [closed] });
1080
+ this.#cache?.messages.invalidate(chatId);
1081
+ this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId });
1082
+ const after = asArray(record(answer.message)?.attaches).find((each) => each._type === "POLL");
1083
+ return { chatId, messageId, poll: (after && toPoll(after)) ?? { ...poll, closed: true } };
1084
+ }
1085
+ catch (error) {
1086
+ this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: asCliError(error).code });
1087
+ throw error;
1088
+ }
1089
+ },
1090
+ /**
1091
+ * A new message whose one attachment is the poll — so it goes through `#deliver`, with its `cid`
1092
+ * and its one retry, like any message.
1093
+ */
1094
+ create: async (chatId, question, answers, options = {}) => {
1095
+ if (this.#offline)
1096
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot create a poll");
1097
+ if (question.trim() === "")
1098
+ throw new CliError("validation_error", "a poll needs a question");
1099
+ if (answers.length < 2 || answers.some((answer) => answer.trim() === "")) {
1100
+ throw new CliError("validation_error", "a poll needs at least two answers, none of them empty");
1101
+ }
1102
+ try {
1103
+ this.#sends?.check({ chatId, kind: "message", ...(options.cid === undefined ? {} : { cid: options.cid }) });
1104
+ }
1105
+ catch (error) {
1106
+ this.#sends?.record({ chatId, kind: "message", outcome: "refused", errorCode: asCliError(error).code });
1107
+ throw error;
1108
+ }
1109
+ const cid = options.cid ?? this.#nextCid();
1110
+ const attach = {
1111
+ _type: "POLL",
1112
+ title: question,
1113
+ answers: answers.map((text) => ({ text })),
1114
+ settings: pollSettings(options),
1115
+ };
1116
+ try {
1117
+ const sent = await this.#deliver(chatId, "", cid, {
1118
+ ...options,
1119
+ attaches: [attach],
1120
+ repeat: `max polls create ${chatId} …`,
1121
+ });
1122
+ this.#sends?.record({
1123
+ chatId,
1124
+ kind: "message",
1125
+ outcome: "sent",
1126
+ messageId: sent.id,
1127
+ cid,
1128
+ length: question.length,
1129
+ });
1130
+ return sent;
1131
+ }
1132
+ catch (error) {
1133
+ const failure = asCliError(error);
1134
+ this.#sends?.record({
1135
+ chatId,
1136
+ kind: "message",
1137
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
1138
+ cid,
1139
+ errorCode: failure.code,
1140
+ });
1141
+ throw error;
1142
+ }
1143
+ },
1144
+ };
1145
+ /** The poll of one message, whole as MAX sends it and read, or a refusal that says why there is none. */
1146
+ async #poll(chatId, messageId) {
1147
+ const raw = await this.#rawMessage(chatId, messageId);
1148
+ const attach = asArray(raw.attaches).find((each) => each._type === "POLL");
1149
+ if (!attach)
1150
+ throw new CliError("validation_error", `message ${messageId} carries no poll`);
1151
+ const poll = toPoll(attach);
1152
+ if (!poll)
1153
+ throw new CliError("validation_error", `the poll of message ${messageId} is a newer kind this version cannot read`);
1154
+ if (poll.closed)
1155
+ throw new CliError("validation_error", `the poll of message ${messageId} is closed`);
1156
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
1157
+ return { attach, poll, outgoing: toMessage(raw, chatId, lookup).outgoing === true };
1158
+ }
995
1159
  folders = {
996
1160
  list: async () => (await this.#folders()).map(toFolder),
997
1161
  create: (title, chats = []) => this.#change("folder-create", async () => {
@@ -1208,7 +1372,7 @@ export class MaxClient {
1208
1372
  async #deliver(chatId, text, cid, options) {
1209
1373
  await this.#connectOnce();
1210
1374
  const session = this.#session();
1211
- const attaches = [];
1375
+ const attaches = [...(options.attaches ?? [])];
1212
1376
  for (const file of options.files ?? [])
1213
1377
  attaches.push(await this.#upload(file));
1214
1378
  const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
@@ -1703,6 +1867,20 @@ export class MaxClient {
1703
1867
  forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
1704
1868
  }));
1705
1869
  }
1870
+ /** The chat's owner and admins as the login carried them; `undefined` when it did not. */
1871
+ async #roles(chatId) {
1872
+ await this.#connectOnce();
1873
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
1874
+ if (!raw || (raw.owner === undefined && raw.admins === undefined && raw.adminParticipants === undefined)) {
1875
+ return undefined;
1876
+ }
1877
+ const admins = [
1878
+ ...(Array.isArray(raw.admins) ? raw.admins.map(asId) : []),
1879
+ ...Object.keys(record(raw.adminParticipants) ?? {}),
1880
+ ].filter((id) => id !== undefined);
1881
+ const owner = asId(raw.owner);
1882
+ return { ...(owner ? { owner } : {}), admins: new Set(admins.filter((id) => id !== owner)) };
1883
+ }
1706
1884
  /** Names we hold first, the rest from `CONTACT_INFO`, kept for next time. A refusal costs the names only. */
1707
1885
  async #namesOf(ids) {
1708
1886
  const names = this.#cache?.people.names(ids) ?? new Map();
@@ -1832,14 +2010,25 @@ export class MaxClient {
1832
2010
  * Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
1833
2011
  * retried: a failure here happens before `MSG_SEND`, so nothing was sent.
1834
2012
  */
1835
- async #upload({ path, bytes, photo }) {
2013
+ async #upload({ path, bytes, kind, voice }) {
1836
2014
  const request = { count: 1, type: 0, uploaderType: 0, profile: false };
1837
- if (photo) {
2015
+ if (kind === "photo") {
1838
2016
  const { url } = await this.#wire.uploads.photo(request);
1839
2017
  if (typeof url !== "string")
1840
2018
  throw new CliError("provider_error", "MAX gave no address to upload the photo to");
1841
2019
  return { _type: "PHOTO", photoToken: await uploadPhoto(url, path, bytes) };
1842
2020
  }
2021
+ if (kind === "video" || kind === "voice") {
2022
+ const slot = voice ? { ...request, type: 2, uploaderType: 1 } : request;
2023
+ const info = record(asArray((await this.#wire.uploads.video(slot)).info)[0]) ?? {};
2024
+ if (typeof info.url !== "string" || info.videoId === undefined) {
2025
+ throw new CliError("provider_error", `MAX gave no address to upload the ${kind} to`);
2026
+ }
2027
+ await uploadMedia(voice ? "voice message" : "video", info.url, path, bytes);
2028
+ return voice
2029
+ ? { _type: "AUDIO", audioId: info.videoId, duration: voice.durationMs, wave: voice.wave, token: info.token }
2030
+ : { _type: "VIDEO", videoId: info.videoId, token: info.token, videoType: 0 };
2031
+ }
1843
2032
  const info = record(asArray((await this.#wire.uploads.file(request)).info)[0]) ?? {};
1844
2033
  if (typeof info.url !== "string" || info.fileId === undefined) {
1845
2034
  throw new CliError("provider_error", "MAX gave no address to upload the file to");
@@ -2161,6 +2350,8 @@ const REVIEW_CHATS = 50;
2161
2350
  const REVIEW_PAGE = 100;
2162
2351
  const REVIEW_PER_CHAT = 500;
2163
2352
  const EVENTS_READ = 2000;
2353
+ /** How far back `chats events` looks without `since`. */
2354
+ export const EVENTS_DAYS = 7;
2164
2355
  export const FIRST_TAB_SYNC = { folders: 0, calls: 0, assets: {} };
2165
2356
  const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
2166
2357
  const eventTime = (chat) => {