@leemour/max-cli 0.13.0 → 0.15.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 (210) hide show
  1. package/README.md +92 -14
  2. package/dist/bot/auth.d.ts +23 -0
  3. package/dist/bot/auth.d.ts.map +1 -0
  4. package/dist/bot/auth.js +36 -0
  5. package/dist/bot/auth.js.map +1 -0
  6. package/dist/bot/client.d.ts +20 -0
  7. package/dist/bot/client.d.ts.map +1 -0
  8. package/dist/bot/client.js +91 -0
  9. package/dist/bot/client.js.map +1 -0
  10. package/dist/bot/generated/manifest.d.ts +3 -0
  11. package/dist/bot/generated/manifest.d.ts.map +1 -0
  12. package/dist/bot/generated/manifest.js +1232 -0
  13. package/dist/bot/generated/manifest.js.map +1 -0
  14. package/dist/bot/generated/schemas.d.ts +271 -0
  15. package/dist/bot/generated/schemas.d.ts.map +1 -0
  16. package/dist/bot/generated/schemas.js +869 -0
  17. package/dist/bot/generated/schemas.js.map +1 -0
  18. package/dist/bot/generated/types.d.ts +1088 -0
  19. package/dist/bot/generated/types.d.ts.map +1 -0
  20. package/dist/bot/generated/types.js +5 -0
  21. package/dist/bot/generated/types.js.map +1 -0
  22. package/dist/bot/input.d.ts +10 -0
  23. package/dist/bot/input.d.ts.map +1 -0
  24. package/dist/bot/input.js +88 -0
  25. package/dist/bot/input.js.map +1 -0
  26. package/dist/bot/keep.d.ts +25 -0
  27. package/dist/bot/keep.d.ts.map +1 -0
  28. package/dist/bot/keep.js +96 -0
  29. package/dist/bot/keep.js.map +1 -0
  30. package/dist/bot/map.d.ts +11 -0
  31. package/dist/bot/map.d.ts.map +1 -0
  32. package/dist/bot/map.js +76 -0
  33. package/dist/bot/map.js.map +1 -0
  34. package/dist/bot/permissions.d.ts +14 -0
  35. package/dist/bot/permissions.d.ts.map +1 -0
  36. package/dist/bot/permissions.js +53 -0
  37. package/dist/bot/permissions.js.map +1 -0
  38. package/dist/bot/registry.d.ts +30 -0
  39. package/dist/bot/registry.d.ts.map +1 -0
  40. package/dist/bot/registry.js +85 -0
  41. package/dist/bot/registry.js.map +1 -0
  42. package/dist/bot/russian-trusted-root.d.ts +8 -0
  43. package/dist/bot/russian-trusted-root.d.ts.map +1 -0
  44. package/dist/bot/russian-trusted-root.js +41 -0
  45. package/dist/bot/russian-trusted-root.js.map +1 -0
  46. package/dist/bot/transport.d.ts +41 -0
  47. package/dist/bot/transport.d.ts.map +1 -0
  48. package/dist/bot/transport.js +200 -0
  49. package/dist/bot/transport.js.map +1 -0
  50. package/dist/bot/uploads.d.ts +34 -0
  51. package/dist/bot/uploads.d.ts.map +1 -0
  52. package/dist/bot/uploads.js +122 -0
  53. package/dist/bot/uploads.js.map +1 -0
  54. package/dist/client.d.ts +39 -3
  55. package/dist/client.d.ts.map +1 -1
  56. package/dist/client.js +168 -23
  57. package/dist/client.js.map +1 -1
  58. package/dist/commands/bot-comments.d.ts +4 -0
  59. package/dist/commands/bot-comments.d.ts.map +1 -0
  60. package/dist/commands/bot-comments.js +97 -0
  61. package/dist/commands/bot-comments.js.map +1 -0
  62. package/dist/commands/bot-context.d.ts +27 -0
  63. package/dist/commands/bot-context.d.ts.map +1 -0
  64. package/dist/commands/bot-context.js +82 -0
  65. package/dist/commands/bot-context.js.map +1 -0
  66. package/dist/commands/bot-members.d.ts +12 -0
  67. package/dist/commands/bot-members.d.ts.map +1 -0
  68. package/dist/commands/bot-members.js +136 -0
  69. package/dist/commands/bot-members.js.map +1 -0
  70. package/dist/commands/bot-reads.d.ts +7 -0
  71. package/dist/commands/bot-reads.d.ts.map +1 -0
  72. package/dist/commands/bot-reads.js +145 -0
  73. package/dist/commands/bot-reads.js.map +1 -0
  74. package/dist/commands/bot-sends.d.ts +17 -0
  75. package/dist/commands/bot-sends.d.ts.map +1 -0
  76. package/dist/commands/bot-sends.js +270 -0
  77. package/dist/commands/bot-sends.js.map +1 -0
  78. package/dist/commands/bot-setup.d.ts +5 -0
  79. package/dist/commands/bot-setup.d.ts.map +1 -0
  80. package/dist/commands/bot-setup.js +107 -0
  81. package/dist/commands/bot-setup.js.map +1 -0
  82. package/dist/commands/bot-updates.d.ts +3 -0
  83. package/dist/commands/bot-updates.d.ts.map +1 -0
  84. package/dist/commands/bot-updates.js +182 -0
  85. package/dist/commands/bot-updates.js.map +1 -0
  86. package/dist/commands/bot.d.ts +3 -0
  87. package/dist/commands/bot.d.ts.map +1 -0
  88. package/dist/commands/bot.js +160 -0
  89. package/dist/commands/bot.js.map +1 -0
  90. package/dist/commands/chats.d.ts.map +1 -1
  91. package/dist/commands/chats.js +79 -2
  92. package/dist/commands/chats.js.map +1 -1
  93. package/dist/commands/check.d.ts +8 -0
  94. package/dist/commands/check.d.ts.map +1 -0
  95. package/dist/commands/check.js +64 -0
  96. package/dist/commands/check.js.map +1 -0
  97. package/dist/commands/context.d.ts +15 -1
  98. package/dist/commands/context.d.ts.map +1 -1
  99. package/dist/commands/context.js +5 -1
  100. package/dist/commands/context.js.map +1 -1
  101. package/dist/commands/hearing.d.ts +25 -0
  102. package/dist/commands/hearing.d.ts.map +1 -0
  103. package/dist/commands/hearing.js +41 -0
  104. package/dist/commands/hearing.js.map +1 -0
  105. package/dist/commands/inbox.d.ts.map +1 -1
  106. package/dist/commands/inbox.js +19 -3
  107. package/dist/commands/inbox.js.map +1 -1
  108. package/dist/commands/mcp.d.ts +1 -0
  109. package/dist/commands/mcp.d.ts.map +1 -1
  110. package/dist/commands/mcp.js +7 -4
  111. package/dist/commands/mcp.js.map +1 -1
  112. package/dist/commands/messages.d.ts.map +1 -1
  113. package/dist/commands/messages.js +21 -5
  114. package/dist/commands/messages.js.map +1 -1
  115. package/dist/commands/paging.d.ts +3 -1
  116. package/dist/commands/paging.d.ts.map +1 -1
  117. package/dist/commands/paging.js +4 -1
  118. package/dist/commands/paging.js.map +1 -1
  119. package/dist/commands/review.d.ts.map +1 -1
  120. package/dist/commands/review.js +22 -8
  121. package/dist/commands/review.js.map +1 -1
  122. package/dist/commands/rules.d.ts +7 -0
  123. package/dist/commands/rules.d.ts.map +1 -0
  124. package/dist/commands/rules.js +69 -0
  125. package/dist/commands/rules.js.map +1 -0
  126. package/dist/domain/map.d.ts +6 -1
  127. package/dist/domain/map.d.ts.map +1 -1
  128. package/dist/domain/map.js +34 -1
  129. package/dist/domain/map.js.map +1 -1
  130. package/dist/domain/models.d.ts +55 -0
  131. package/dist/domain/models.d.ts.map +1 -1
  132. package/dist/domain/models.js.map +1 -1
  133. package/dist/generated/client.generated.d.ts +1 -0
  134. package/dist/generated/client.generated.d.ts.map +1 -1
  135. package/dist/generated/client.generated.js +1 -0
  136. package/dist/generated/client.generated.js.map +1 -1
  137. package/dist/generated/opcodes.generated.d.ts +1 -0
  138. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  139. package/dist/generated/opcodes.generated.js +1 -0
  140. package/dist/generated/opcodes.generated.js.map +1 -1
  141. package/dist/generated/operations.generated.d.ts +16 -1
  142. package/dist/generated/operations.generated.d.ts.map +1 -1
  143. package/dist/generated/operations.generated.js +2 -1
  144. package/dist/generated/operations.generated.js.map +1 -1
  145. package/dist/mcp/instructions.d.ts +2 -1
  146. package/dist/mcp/instructions.d.ts.map +1 -1
  147. package/dist/mcp/instructions.js +2 -1
  148. package/dist/mcp/instructions.js.map +1 -1
  149. package/dist/mcp/server.d.ts +2 -1
  150. package/dist/mcp/server.d.ts.map +1 -1
  151. package/dist/mcp/server.js +4 -1
  152. package/dist/mcp/server.js.map +1 -1
  153. package/dist/mcp/tools.d.ts +5 -1
  154. package/dist/mcp/tools.d.ts.map +1 -1
  155. package/dist/mcp/tools.js +160 -13
  156. package/dist/mcp/tools.js.map +1 -1
  157. package/dist/moderation/check.d.ts +98 -0
  158. package/dist/moderation/check.d.ts.map +1 -0
  159. package/dist/moderation/check.js +306 -0
  160. package/dist/moderation/check.js.map +1 -0
  161. package/dist/moderation/rules.d.ts +56 -0
  162. package/dist/moderation/rules.d.ts.map +1 -0
  163. package/dist/moderation/rules.js +138 -0
  164. package/dist/moderation/rules.js.map +1 -0
  165. package/dist/program.d.ts.map +1 -1
  166. package/dist/program.js +18 -3
  167. package/dist/program.js.map +1 -1
  168. package/dist/protocol/connection.d.ts.map +1 -1
  169. package/dist/protocol/connection.js +12 -1
  170. package/dist/protocol/connection.js.map +1 -1
  171. package/dist/review.d.ts +32 -5
  172. package/dist/review.d.ts.map +1 -1
  173. package/dist/review.js +82 -45
  174. package/dist/review.js.map +1 -1
  175. package/dist/sends/journal.d.ts +1 -1
  176. package/dist/sends/journal.d.ts.map +1 -1
  177. package/dist/session/store.d.ts +2 -0
  178. package/dist/session/store.d.ts.map +1 -1
  179. package/dist/session/store.js +2 -0
  180. package/dist/session/store.js.map +1 -1
  181. package/dist/spec/index.d.ts.map +1 -1
  182. package/dist/spec/index.js +2 -1
  183. package/dist/spec/index.js.map +1 -1
  184. package/dist/spec/operations/chats.d.ts +4 -1
  185. package/dist/spec/operations/chats.d.ts.map +1 -1
  186. package/dist/spec/operations/chats.js +10 -4
  187. package/dist/spec/operations/chats.js.map +1 -1
  188. package/dist/spec/operations/messages.d.ts.map +1 -1
  189. package/dist/spec/operations/messages.js +9 -4
  190. package/dist/spec/operations/messages.js.map +1 -1
  191. package/dist/spec/operations/uploads.d.ts +13 -0
  192. package/dist/spec/operations/uploads.d.ts.map +1 -1
  193. package/dist/spec/operations/uploads.js +21 -0
  194. package/dist/spec/operations/uploads.js.map +1 -1
  195. package/dist/transcribe/index.d.ts +36 -1
  196. package/dist/transcribe/index.d.ts.map +1 -1
  197. package/dist/transcribe/index.js +91 -14
  198. package/dist/transcribe/index.js.map +1 -1
  199. package/dist/upload.d.ts +3 -0
  200. package/dist/upload.d.ts.map +1 -1
  201. package/dist/upload.js +13 -7
  202. package/dist/upload.js.map +1 -1
  203. package/dist/version.d.ts +1 -1
  204. package/dist/version.js +1 -1
  205. package/dist/voice.d.ts +9 -0
  206. package/dist/voice.d.ts.map +1 -0
  207. package/dist/voice.js +36 -0
  208. package/dist/voice.js.map +1 -0
  209. package/package.json +17 -6
  210. package/skills/max-cli/SKILL.md +23 -2
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, toMessage, toProfile, toReactions, toSession, } from "./domain/map.js";
2
+ import { namesFrom, SETTING_FLAGS, toChat, toContact, toFolder, toGroupCard, toGroupMember, toMessage, 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
@@ -202,6 +203,69 @@ export class MaxClient {
202
203
  const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
203
204
  return raw ? this.#partnerOf(raw) : undefined;
204
205
  },
206
+ /**
207
+ * The owner and admins of a group, from the chat as the login carried it (`owner`, `admins`,
208
+ * `adminParticipants` — measured 2026-09-24, `FIND-116`). `undefined` when the login did not
209
+ * carry this chat or its admins: the login carries only chats that changed lately, and the rest
210
+ * come from the local copy.
211
+ */
212
+ adminIds: async (chatId) => {
213
+ const roles = await this.#roles(chatId);
214
+ return roles && [...new Set([...(roles.owner ? [roles.owner] : []), ...roles.admins])];
215
+ },
216
+ /**
217
+ * Who joined, left, was added or removed since a point: the service messages in the chat's
218
+ * history, read forward from `since` without reactions. At most `EVENTS_READ` messages, the oldest.
219
+ */
220
+ events: async (reference, { since = Date.now() - EVENTS_DAYS * 86_400_000 } = {}) => {
221
+ if (this.#offline)
222
+ throw new CliError("validation_error", "`--offline` has no history to read events from");
223
+ const chatId = await this.chats.resolve(reference);
224
+ const { messages: read, more } = await this.chats.since(chatId, since);
225
+ const found = read.flatMap((message) => message.attachments
226
+ .filter((attachment) => attachment.kind === "control" && attachment.event !== undefined)
227
+ .map((attachment) => ({ message, attachment })));
228
+ const contacts = namesFrom(this.#session().contacts);
229
+ const ids = [
230
+ ...new Set([
231
+ ...found.flatMap(({ attachment }) => attachment.userIds ?? []),
232
+ ...found.flatMap(({ message }) => message.senderName === null && message.senderId ? [message.senderId] : []),
233
+ ]),
234
+ ];
235
+ const names = await this.#namesOf(ids.filter((id) => !contacts.has(id)));
236
+ const nameOf = (id) => contacts.get(id) ?? names.get(id) ?? null;
237
+ return {
238
+ chatId,
239
+ since: new Date(since).toISOString(),
240
+ more,
241
+ events: found.map(({ message, attachment }) => ({
242
+ messageId: message.id,
243
+ timestamp: message.timestamp,
244
+ event: attachment.event ?? "",
245
+ by: { id: message.senderId, name: message.senderName ?? (message.senderId && nameOf(message.senderId)) },
246
+ people: (attachment.userIds ?? []).map((id) => ({ id, name: nameOf(id) })),
247
+ ...(attachment.title ? { title: attachment.title } : {}),
248
+ })),
249
+ };
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
+ },
205
269
  /** What a link leads to, without joining it. */
206
270
  inspect: async (link) => {
207
271
  if (this.#offline)
@@ -271,6 +335,43 @@ export class MaxClient {
271
335
  }, userIds);
272
336
  },
273
337
  members: {
338
+ /**
339
+ * Everyone in a group or channel, from MAX, page by page. Paging follows PyMax: each answer's
340
+ * `marker` asks for the next page, and none means the last. Stops at `MEMBERS_READ`, or when a
341
+ * page repeats its marker or brings nobody new, so a misread marker cannot loop.
342
+ */
343
+ list: async (reference) => {
344
+ if (this.#offline)
345
+ throw new CliError("validation_error", "`--offline` has no member list; MAX has");
346
+ const chatId = await this.chats.resolve(reference);
347
+ await this.#connectOnce();
348
+ const members = new Map();
349
+ let marker = 0;
350
+ let complete = false;
351
+ while (members.size < MEMBERS_READ) {
352
+ const answer = await this.#wire.chats.members({ chatId, type: "MEMBER", marker, count: MEMBERS_PAGE });
353
+ const before = members.size;
354
+ for (const raw of asArray(answer.members)) {
355
+ const member = toGroupMember(raw);
356
+ if (member.id)
357
+ members.set(member.id, member);
358
+ }
359
+ const next = typeof answer.marker === "number" ? answer.marker : 0;
360
+ if (!next || next === marker || members.size === before) {
361
+ complete = !next;
362
+ break;
363
+ }
364
+ marker = next;
365
+ }
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
+ };
374
+ },
274
375
  /** No history unless asked (`NEED-272`): what was said before somebody joined is not theirs by default. */
275
376
  add: (reference, people, { history = false } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
276
377
  remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
@@ -284,13 +385,14 @@ export class MaxClient {
284
385
  remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
285
386
  },
286
387
  requests: {
388
+ /** Each with the age of their account, read like a member's — the answer's `{contact}` is the same shape. */
287
389
  list: async (reference) => {
288
390
  if (this.#offline)
289
391
  throw new CliError("validation_error", "`--offline` reads what was recorded; join requests never are");
290
392
  const chatId = await this.chats.resolve(reference);
291
393
  await this.#connectOnce();
292
394
  const answer = await this.#wire.chats.members({ chatId, type: "JOIN_REQUEST", count: JOIN_REQUESTS });
293
- return asArray(answer.members).map((member) => toContact(record(member.contact) ?? {}));
395
+ return asArray(answer.members).map(toGroupMember);
294
396
  },
295
397
  accept: (reference, people) => this.#updateMembers(reference, people, "requests.accept", {
296
398
  operation: "add",
@@ -693,6 +795,10 @@ export class MaxClient {
693
795
  send: async (chatId, text, options = {}) => {
694
796
  if (this.#offline)
695
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
+ }
696
802
  if (options.at !== undefined && options.notify === false) {
697
803
  // The web client always sends a scheduled message with `notify: true`; §34.
698
804
  throw new CliError("validation_error", "a scheduled message cannot be silent — MAX's own client never sends one");
@@ -704,11 +810,16 @@ export class MaxClient {
704
810
  const files = await Promise.all((options.files ?? []).map(async (path) => ({
705
811
  path,
706
812
  bytes: await readUpload(path, { anyFile: options.anyFile === true }),
707
- photo: isImage(path),
813
+ kind: isImage(path) ? "photo" : isVideo(path) && options.asFile !== true ? "video" : "file",
708
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
+ }
709
819
  // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
710
- if (files.some((file) => !file.photo) && files.length > 1) {
711
- 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");
712
823
  }
713
824
  // Before connecting: a refused send never opens a socket when the chat was given as an id.
714
825
  try {
@@ -724,10 +835,7 @@ export class MaxClient {
724
835
  throw error;
725
836
  }
726
837
  const cid = options.cid ?? this.#nextCid();
727
- const attachments = files.map(({ bytes, photo }) => ({
728
- kind: photo ? "photo" : "file",
729
- bytes: bytes.length,
730
- }));
838
+ const attachments = files.map(({ bytes, kind }) => ({ kind, bytes: bytes.length }));
731
839
  const summary = {
732
840
  ...(attachments.length > 0 ? { attachments } : {}),
733
841
  ...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
@@ -1003,8 +1111,9 @@ export class MaxClient {
1003
1111
  * forward from `since`; a chat with more than `REVIEW_PER_CHAT` in the window is cut short and
1004
1112
  * says so.
1005
1113
  */
1006
- review: async ({ since }) => {
1007
- const { chats, changed, cut } = await this.#changedSince(since);
1114
+ review: async ({ since, chatId, }) => {
1115
+ const { chats, changed: all, cut } = await this.#changedSince(since);
1116
+ const changed = chatId === undefined ? all : all.filter((chat) => chat.id === chatId);
1008
1117
  const { read, skipped } = capped(changed, REVIEW_CHATS);
1009
1118
  const found = [];
1010
1119
  for (const { id, title, kind } of read) {
@@ -1594,6 +1703,32 @@ export class MaxClient {
1594
1703
  const ids = [...new Set([...messages, ...quoted].filter(needsName).map((message) => message.senderId))];
1595
1704
  if (ids.length === 0)
1596
1705
  return messages;
1706
+ const names = await this.#namesOf(ids);
1707
+ const name = (message) => needsName(message) && names.has(message.senderId)
1708
+ ? { ...message, senderName: names.get(message.senderId) ?? null }
1709
+ : message;
1710
+ return messages.map((message) => ({
1711
+ ...name(message),
1712
+ replyTo: message.replyTo && name(message.replyTo),
1713
+ forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
1714
+ }));
1715
+ }
1716
+ /** The chat's owner and admins as the login carried them; `undefined` when it did not. */
1717
+ async #roles(chatId) {
1718
+ await this.#connectOnce();
1719
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
1720
+ if (!raw || (raw.owner === undefined && raw.admins === undefined && raw.adminParticipants === undefined)) {
1721
+ return undefined;
1722
+ }
1723
+ const admins = [
1724
+ ...(Array.isArray(raw.admins) ? raw.admins.map(asId) : []),
1725
+ ...Object.keys(record(raw.adminParticipants) ?? {}),
1726
+ ].filter((id) => id !== undefined);
1727
+ const owner = asId(raw.owner);
1728
+ return { ...(owner ? { owner } : {}), admins: new Set(admins.filter((id) => id !== owner)) };
1729
+ }
1730
+ /** Names we hold first, the rest from `CONTACT_INFO`, kept for next time. A refusal costs the names only. */
1731
+ async #namesOf(ids) {
1597
1732
  const names = this.#cache?.people.names(ids) ?? new Map();
1598
1733
  const fetched = [];
1599
1734
  try {
@@ -1609,18 +1744,11 @@ export class MaxClient {
1609
1744
  }
1610
1745
  }
1611
1746
  catch (error) {
1612
- this.#warnAbout("names_unread", `some senders are shown by id: their names could not be looked up (${reasonOf(error)})`);
1747
+ this.#warnAbout("names_unread", `some people are shown by id: their names could not be looked up (${reasonOf(error)})`);
1613
1748
  }
1614
1749
  if (fetched.length > 0)
1615
1750
  this.#cache?.people.upsert(fetched, "info");
1616
- const name = (message) => needsName(message) && names.has(message.senderId)
1617
- ? { ...message, senderName: names.get(message.senderId) ?? null }
1618
- : message;
1619
- return messages.map((message) => ({
1620
- ...name(message),
1621
- replyTo: message.replyTo && name(message.replyTo),
1622
- forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
1623
- }));
1751
+ return names;
1624
1752
  }
1625
1753
  /** INIT with the profile's own device, which is the device MAX then issues the token to (bite 8). */
1626
1754
  async #beforeLogin(flow) {
@@ -1728,14 +1856,25 @@ export class MaxClient {
1728
1856
  * Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
1729
1857
  * retried: a failure here happens before `MSG_SEND`, so nothing was sent.
1730
1858
  */
1731
- async #upload({ path, bytes, photo }) {
1859
+ async #upload({ path, bytes, kind, voice }) {
1732
1860
  const request = { count: 1, type: 0, uploaderType: 0, profile: false };
1733
- if (photo) {
1861
+ if (kind === "photo") {
1734
1862
  const { url } = await this.#wire.uploads.photo(request);
1735
1863
  if (typeof url !== "string")
1736
1864
  throw new CliError("provider_error", "MAX gave no address to upload the photo to");
1737
1865
  return { _type: "PHOTO", photoToken: await uploadPhoto(url, path, bytes) };
1738
1866
  }
1867
+ if (kind === "video" || kind === "voice") {
1868
+ const slot = voice ? { ...request, type: 2, uploaderType: 1 } : request;
1869
+ const info = record(asArray((await this.#wire.uploads.video(slot)).info)[0]) ?? {};
1870
+ if (typeof info.url !== "string" || info.videoId === undefined) {
1871
+ throw new CliError("provider_error", `MAX gave no address to upload the ${kind} to`);
1872
+ }
1873
+ await uploadMedia(voice ? "voice message" : "video", info.url, path, bytes);
1874
+ return voice
1875
+ ? { _type: "AUDIO", audioId: info.videoId, duration: voice.durationMs, wave: voice.wave, token: info.token }
1876
+ : { _type: "VIDEO", videoId: info.videoId, token: info.token, videoType: 0 };
1877
+ }
1739
1878
  const info = record(asArray((await this.#wire.uploads.file(request)).info)[0]) ?? {};
1740
1879
  if (typeof info.url !== "string" || info.fileId === undefined) {
1741
1880
  throw new CliError("provider_error", "MAX gave no address to upload the file to");
@@ -1987,6 +2126,9 @@ export const ADMIN_RIGHTS = {
1987
2126
  /** The most chats `max serve` holds from pushes before it logs in again instead. */
1988
2127
  const LIVE_CHATS = 10_000;
1989
2128
  const JOIN_REQUESTS = 100;
2129
+ /** PyMax's page, the one measured. */
2130
+ const MEMBERS_PAGE = 50;
2131
+ const MEMBERS_READ = 5000;
1990
2132
  /**
1991
2133
  * A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
1992
2134
  * measured. A public one (`https://max.ru/<name>`) goes whole, which is PyMax's claim only.
@@ -2053,6 +2195,9 @@ const INBOX_CHATS = 20;
2053
2195
  const REVIEW_CHATS = 50;
2054
2196
  const REVIEW_PAGE = 100;
2055
2197
  const REVIEW_PER_CHAT = 500;
2198
+ const EVENTS_READ = 2000;
2199
+ /** How far back `chats events` looks without `since`. */
2200
+ export const EVENTS_DAYS = 7;
2056
2201
  export const FIRST_TAB_SYNC = { folders: 0, calls: 0, assets: {} };
2057
2202
  const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
2058
2203
  const eventTime = (chat) => {