@leemour/max-cli 0.4.0 → 0.6.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 (190) hide show
  1. package/README.md +14 -5
  2. package/dist/bin/max.js +0 -0
  3. package/dist/cache/store.d.ts +14 -8
  4. package/dist/cache/store.d.ts.map +1 -1
  5. package/dist/cache/store.js +30 -5
  6. package/dist/cache/store.js.map +1 -1
  7. package/dist/client.d.ts +93 -5
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +347 -59
  10. package/dist/client.js.map +1 -1
  11. package/dist/commands/chats.d.ts.map +1 -1
  12. package/dist/commands/chats.js +20 -0
  13. package/dist/commands/chats.js.map +1 -1
  14. package/dist/commands/commands.d.ts +8 -0
  15. package/dist/commands/commands.d.ts.map +1 -0
  16. package/dist/commands/commands.js +36 -0
  17. package/dist/commands/commands.js.map +1 -0
  18. package/dist/commands/config.d.ts.map +1 -1
  19. package/dist/commands/config.js +29 -0
  20. package/dist/commands/config.js.map +1 -1
  21. package/dist/commands/contacts.d.ts.map +1 -1
  22. package/dist/commands/contacts.js +18 -0
  23. package/dist/commands/contacts.js.map +1 -1
  24. package/dist/commands/context.d.ts +18 -0
  25. package/dist/commands/context.d.ts.map +1 -1
  26. package/dist/commands/context.js +22 -0
  27. package/dist/commands/context.js.map +1 -1
  28. package/dist/commands/inbox.d.ts +14 -0
  29. package/dist/commands/inbox.d.ts.map +1 -0
  30. package/dist/commands/inbox.js +90 -0
  31. package/dist/commands/inbox.js.map +1 -0
  32. package/dist/commands/messages.d.ts.map +1 -1
  33. package/dist/commands/messages.js +55 -4
  34. package/dist/commands/messages.js.map +1 -1
  35. package/dist/commands/reactions.d.ts +3 -0
  36. package/dist/commands/reactions.d.ts.map +1 -0
  37. package/dist/commands/reactions.js +26 -0
  38. package/dist/commands/reactions.js.map +1 -0
  39. package/dist/commands/recipients.d.ts +9 -0
  40. package/dist/commands/recipients.d.ts.map +1 -0
  41. package/dist/commands/recipients.js +68 -0
  42. package/dist/commands/recipients.js.map +1 -0
  43. package/dist/commands/sends.d.ts +4 -0
  44. package/dist/commands/sends.d.ts.map +1 -0
  45. package/dist/commands/sends.js +21 -0
  46. package/dist/commands/sends.js.map +1 -0
  47. package/dist/commands/session.d.ts.map +1 -1
  48. package/dist/commands/session.js +80 -15
  49. package/dist/commands/session.js.map +1 -1
  50. package/dist/commands/skill.d.ts.map +1 -1
  51. package/dist/commands/skill.js +2 -1
  52. package/dist/commands/skill.js.map +1 -1
  53. package/dist/config.d.ts +54 -13
  54. package/dist/config.d.ts.map +1 -1
  55. package/dist/config.js +98 -16
  56. package/dist/config.js.map +1 -1
  57. package/dist/domain/map.d.ts +3 -1
  58. package/dist/domain/map.d.ts.map +1 -1
  59. package/dist/domain/map.js +15 -1
  60. package/dist/domain/map.js.map +1 -1
  61. package/dist/domain/models.d.ts +55 -1
  62. package/dist/domain/models.d.ts.map +1 -1
  63. package/dist/domain/models.js.map +1 -1
  64. package/dist/download.d.ts +15 -0
  65. package/dist/download.d.ts.map +1 -0
  66. package/dist/download.js +60 -0
  67. package/dist/download.js.map +1 -0
  68. package/dist/generated/client.generated.d.ts +14 -0
  69. package/dist/generated/client.generated.d.ts.map +1 -1
  70. package/dist/generated/client.generated.js +14 -0
  71. package/dist/generated/client.generated.js.map +1 -1
  72. package/dist/generated/opcodes.generated.d.ts +15 -0
  73. package/dist/generated/opcodes.generated.d.ts.map +1 -1
  74. package/dist/generated/opcodes.generated.js +15 -0
  75. package/dist/generated/opcodes.generated.js.map +1 -1
  76. package/dist/generated/operations.generated.d.ts +99 -1
  77. package/dist/generated/operations.generated.d.ts.map +1 -1
  78. package/dist/generated/operations.generated.js +13 -1
  79. package/dist/generated/operations.generated.js.map +1 -1
  80. package/dist/markdown.d.ts +18 -0
  81. package/dist/markdown.d.ts.map +1 -0
  82. package/dist/markdown.js +52 -0
  83. package/dist/markdown.js.map +1 -0
  84. package/dist/program.d.ts.map +1 -1
  85. package/dist/program.js +10 -0
  86. package/dist/program.js.map +1 -1
  87. package/dist/rendering/messages.d.ts.map +1 -1
  88. package/dist/rendering/messages.js +6 -0
  89. package/dist/rendering/messages.js.map +1 -1
  90. package/dist/resolve.d.ts +15 -0
  91. package/dist/resolve.d.ts.map +1 -0
  92. package/dist/resolve.js +48 -0
  93. package/dist/resolve.js.map +1 -0
  94. package/dist/runs/recording.d.ts.map +1 -1
  95. package/dist/runs/recording.js +7 -0
  96. package/dist/runs/recording.js.map +1 -1
  97. package/dist/runs/run.d.ts +2 -0
  98. package/dist/runs/run.d.ts.map +1 -1
  99. package/dist/runs/run.js +1 -0
  100. package/dist/runs/run.js.map +1 -1
  101. package/dist/sends/guard.d.ts +26 -0
  102. package/dist/sends/guard.d.ts.map +1 -0
  103. package/dist/sends/guard.js +48 -0
  104. package/dist/sends/guard.js.map +1 -0
  105. package/dist/sends/journal.d.ts +29 -0
  106. package/dist/sends/journal.d.ts.map +1 -0
  107. package/dist/sends/journal.js +40 -0
  108. package/dist/sends/journal.js.map +1 -0
  109. package/dist/sends/recipients.d.ts +29 -0
  110. package/dist/sends/recipients.d.ts.map +1 -0
  111. package/dist/sends/recipients.js +62 -0
  112. package/dist/sends/recipients.js.map +1 -0
  113. package/dist/session/adopt.d.ts.map +1 -1
  114. package/dist/session/adopt.js +7 -6
  115. package/dist/session/adopt.js.map +1 -1
  116. package/dist/session/browser.d.ts +55 -0
  117. package/dist/session/browser.d.ts.map +1 -0
  118. package/dist/session/browser.js +292 -0
  119. package/dist/session/browser.js.map +1 -0
  120. package/dist/session/handshake.d.ts +3 -1
  121. package/dist/session/handshake.d.ts.map +1 -1
  122. package/dist/session/handshake.js +3 -1
  123. package/dist/session/handshake.js.map +1 -1
  124. package/dist/session/login.d.ts +20 -0
  125. package/dist/session/login.d.ts.map +1 -0
  126. package/dist/session/login.js +66 -0
  127. package/dist/session/login.js.map +1 -0
  128. package/dist/session/prompt.d.ts +3 -1
  129. package/dist/session/prompt.d.ts.map +1 -1
  130. package/dist/session/prompt.js +2 -2
  131. package/dist/session/prompt.js.map +1 -1
  132. package/dist/session/qr-terminal.d.ts +9 -0
  133. package/dist/session/qr-terminal.d.ts.map +1 -0
  134. package/dist/session/qr-terminal.js +34 -0
  135. package/dist/session/qr-terminal.js.map +1 -0
  136. package/dist/session/store.d.ts +2 -0
  137. package/dist/session/store.d.ts.map +1 -1
  138. package/dist/session/store.js +2 -0
  139. package/dist/session/store.js.map +1 -1
  140. package/dist/spec/index.d.ts.map +1 -1
  141. package/dist/spec/index.js +14 -1
  142. package/dist/spec/index.js.map +1 -1
  143. package/dist/spec/operations/attachments.d.ts +15 -0
  144. package/dist/spec/operations/attachments.d.ts.map +1 -0
  145. package/dist/spec/operations/attachments.js +38 -0
  146. package/dist/spec/operations/attachments.js.map +1 -0
  147. package/dist/spec/operations/login.d.ts +64 -0
  148. package/dist/spec/operations/login.d.ts.map +1 -0
  149. package/dist/spec/operations/login.js +99 -0
  150. package/dist/spec/operations/login.js.map +1 -0
  151. package/dist/spec/operations/messages.d.ts +27 -1
  152. package/dist/spec/operations/messages.d.ts.map +1 -1
  153. package/dist/spec/operations/messages.js +46 -2
  154. package/dist/spec/operations/messages.js.map +1 -1
  155. package/dist/testing/mock-max.d.ts +10 -4
  156. package/dist/testing/mock-max.d.ts.map +1 -1
  157. package/dist/testing/mock-max.js +5 -1
  158. package/dist/testing/mock-max.js.map +1 -1
  159. package/dist/testing/sandbox.js +3 -0
  160. package/dist/testing/sandbox.js.map +1 -1
  161. package/dist/testing/unscripted.d.ts +2 -0
  162. package/dist/testing/unscripted.d.ts.map +1 -0
  163. package/dist/testing/unscripted.js +18 -0
  164. package/dist/testing/unscripted.js.map +1 -0
  165. package/dist/version.d.ts +1 -1
  166. package/dist/version.js +1 -1
  167. package/package.json +31 -22
  168. package/skills/max-cli/SKILL.md +25 -6
  169. package/dist/commands/login.d.ts +0 -15
  170. package/dist/commands/login.d.ts.map +0 -1
  171. package/dist/commands/login.js +0 -43
  172. package/dist/commands/login.js.map +0 -1
  173. package/dist/commands/logout.d.ts +0 -10
  174. package/dist/commands/logout.d.ts.map +0 -1
  175. package/dist/commands/logout.js +0 -22
  176. package/dist/commands/logout.js.map +0 -1
  177. package/dist/commands/me.d.ts +0 -3
  178. package/dist/commands/me.d.ts.map +0 -1
  179. package/dist/commands/me.js +0 -18
  180. package/dist/commands/me.js.map +0 -1
  181. package/dist/commands/send.d.ts +0 -10
  182. package/dist/commands/send.d.ts.map +0 -1
  183. package/dist/commands/send.js +0 -30
  184. package/dist/commands/send.js.map +0 -1
  185. package/dist/protocol/session.d.ts +0 -54
  186. package/dist/protocol/session.d.ts.map +0 -1
  187. package/dist/protocol/session.js +0 -57
  188. package/dist/protocol/session.js.map +0 -1
  189. package/dist/spec/operations/auth.d.ts +0 -42
  190. package/dist/spec/operations/auth.d.ts.map +0 -1
package/dist/client.js CHANGED
@@ -1,11 +1,14 @@
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, toChat, toContact, toMessage, toProfile, toReactions } 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 { isId, pickChat, pickPerson } from "./resolve.js";
7
8
  import { countsIn, idsOf } from "./runs/events.js";
8
- import { startSession } from "./session/handshake.js";
9
+ import { LOGIN_CHATS, startSession } from "./session/handshake.js";
10
+ import { tokenByQr } from "./session/login.js";
11
+ import { WEB_USER_AGENT } from "./spec/identity.js";
9
12
  import { buildRequest, checkResponse } from "./spec/index.js";
10
13
  /**
11
14
  * **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
@@ -26,23 +29,33 @@ export class MaxClient {
26
29
  #cache;
27
30
  #offline;
28
31
  #events;
32
+ #sends;
29
33
  #invoke = ((operation, request) => this.#send(operation, request));
30
34
  #wire = wireClient(this.#invoke);
31
35
  #login;
32
36
  #previousCid = 0;
33
37
  #people;
34
38
  #merged;
35
- constructor({ store, timeoutMs, connection, warn, cache, offline = false, events }) {
39
+ constructor({ store, timeoutMs, connection, warn, cache, offline = false, events, sends }) {
36
40
  this.#store = store;
37
41
  this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
38
42
  this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
39
43
  this.#cache = cache;
40
44
  this.#offline = offline;
41
45
  this.#events = events ?? (() => { });
46
+ this.#sends = sends;
42
47
  }
43
48
  account = {
44
49
  me: () => toProfile(record(this.#session().profile) ?? {}),
45
50
  };
51
+ /**
52
+ * Obtaining a token rather than using one. The token comes back to the caller, unstored and not yet
53
+ * tried: `adoptToken` logs in with it on a connection of its own before the keyring sees it, so
54
+ * a login that went wrong halfway cannot replace a working session.
55
+ */
56
+ login = {
57
+ byQr: (options) => this.#beforeLogin(() => tokenByQr(this.#wire, options)),
58
+ };
46
59
  chats = {
47
60
  /**
48
61
  * Chats come with the login; a limit trims rather than fetching more.
@@ -53,7 +66,7 @@ export class MaxClient {
53
66
  * exactly the chats a person recognises by name.
54
67
  */
55
68
  list: async (options = {}) => {
56
- const { limit, offset = 0, kind } = options;
69
+ const { limit, offset = 0, kind, unread } = options;
57
70
  const query = checkedQuery(options.query);
58
71
  if (this.#offline) {
59
72
  const recorded = this.#cache;
@@ -62,8 +75,8 @@ export class MaxClient {
62
75
  const store = recorded;
63
76
  if (store.chats.count() === 0)
64
77
  this.#recorded(undefined, "chats");
65
- const items = store.chats.page({ limit: limit ?? Number.MAX_SAFE_INTEGER, offset, query, kind });
66
- return { items, hasMore: offset + items.length < store.chats.count({ query, kind }) };
78
+ const items = store.chats.page({ limit: limit ?? Number.MAX_SAFE_INTEGER, offset, query, kind, unread });
79
+ return { items, hasMore: offset + items.length < store.chats.count({ query, kind, unread }) };
67
80
  }
68
81
  await this.#connectOnce();
69
82
  const raw = asArray(this.#session().chats);
@@ -81,13 +94,13 @@ export class MaxClient {
81
94
  });
82
95
  const cache = this.#cache;
83
96
  if (!cache)
84
- return paged(matching(chats, query, kind), limit, offset);
97
+ return paged(matching(chats, { query, kind, unread }), limit, offset);
85
98
  // Written first, then read back: the titles just resolved have to be in the store before it
86
99
  // is asked to order and page over them, and the delta this login carried is only a slice of
87
100
  // what it now holds.
88
101
  cache.chats.write(chats);
89
- const items = cache.chats.page({ limit: limit ?? Number.MAX_SAFE_INTEGER, offset, query, kind });
90
- return { items, hasMore: offset + items.length < cache.chats.count({ query, kind }) };
102
+ const items = cache.chats.page({ limit: limit ?? Number.MAX_SAFE_INTEGER, offset, query, kind, unread });
103
+ return { items, hasMore: offset + items.length < cache.chats.count({ query, kind, unread }) };
91
104
  },
92
105
  /**
93
106
  * Turns what the person typed into a chat id.
@@ -97,21 +110,23 @@ export class MaxClient {
97
110
  * conversation is not undoable, so the caller is shown the candidates and asked to be specific.
98
111
  */
99
112
  resolve: async (reference) => {
100
- if (/^-?\d+$/.test(reference.trim()))
113
+ if (isId(reference))
101
114
  return reference.trim();
102
- const { items: chats } = await this.chats.list();
103
- const wanted = reference.trim().toLowerCase();
104
- const titled = chats.filter((chat) => chat.title !== null);
105
- const exact = titled.filter((chat) => chat.title?.toLowerCase() === wanted);
106
- const matches = exact.length > 0 ? exact : titled.filter((chat) => chat.title?.toLowerCase().includes(wanted));
107
- if (matches.length === 1 && matches[0])
108
- return matches[0].id;
109
- if (matches.length === 0)
110
- throw new CliError("not_found", `no chat matches "${reference}"`);
111
- const candidates = matches.map((chat) => ({ id: chat.id, title: chat.title }));
112
- const width = Math.max(...candidates.map(({ id }) => id.length));
113
- const lines = candidates.map(({ id, title }) => ` ${id.padEnd(width)} ${title}`).join("\n");
114
- throw new CliError("validation_error", `"${reference}" matches ${matches.length} chats — name one by its id:\n${lines}`, { candidates });
115
+ return pickChat(reference, (await this.chats.list()).items).id;
116
+ },
117
+ /**
118
+ * One chat and who is in it, from the list the login just refreshed.
119
+ *
120
+ * ⚠ **An id is checked here, where `resolve` lets any number through** — for a read that is
121
+ * the difference between an answer and a card of nulls for a chat that does not exist.
122
+ */
123
+ show: async (reference) => {
124
+ const { items } = await this.chats.list();
125
+ const chat = isId(reference) ? items.find((one) => one.id === reference.trim()) : pickChat(reference, items);
126
+ if (!chat)
127
+ throw new CliError("not_found", `no chat ${reference.trim()} among this account's chats`);
128
+ const cache = this.#cache;
129
+ return { ...chat, members: chat.kind === "channel" || !cache ? null : cache.chats.members(chat.id) };
115
130
  },
116
131
  };
117
132
  contacts = {
@@ -157,6 +172,31 @@ export class MaxClient {
157
172
  * It is also the only thing that could ever prune somebody MAX has stopped returning, which is
158
173
  * the second reason it exists.
159
174
  */
175
+ /**
176
+ * One person and the chats we share, **whoever they are** — a group member is as findable as
177
+ * a contact. `NEED-105` decides who `list` lists, not who can be looked up.
178
+ *
179
+ * Only from the store: the shared chats are its `chat_members`, which nothing else holds.
180
+ */
181
+ show: async (reference) => {
182
+ const cache = this.#cache;
183
+ if (!cache) {
184
+ throw new CliError("configuration_error", `there is no local store for profile "${this.#store.profile}" to look people up in — the note above says why`);
185
+ }
186
+ if (this.#offline) {
187
+ if (cache.people.count() === 0)
188
+ this.#recorded(undefined, "people");
189
+ }
190
+ else {
191
+ await this.#connectOnce();
192
+ await this.#peopleFor(asArray(this.#session().chats));
193
+ }
194
+ const person = pickPerson(reference, cache);
195
+ const chats = cache.people
196
+ .sharedChats(person.id)
197
+ .map(({ id, title, kind, lastMessageAt }) => ({ id, title, kind, lastMessageAt }));
198
+ return { ...person, chats };
199
+ },
160
200
  sync: async () => {
161
201
  if (this.#offline) {
162
202
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot sync");
@@ -208,16 +248,33 @@ export class MaxClient {
208
248
  * it takes a moment and answers with what came before it — so `--before` is exact where a page
209
249
  * number over a live conversation would repeat and skip rows.
210
250
  *
251
+ * `after` reads the other way and **leaves the anchor out**, so the next page's hint does not
252
+ * repeat a row. MAX puts it in — measured 2026-09-23, `forward: n` with `backward: 0` starts
253
+ * with the message it was given — so one more is asked for and anything not later is dropped.
254
+ * `before` keeps the anchor, as it always has.
255
+ *
211
256
  * `hasMore` here is a claim about the copy we hold, never about the chat: a full page back is
212
257
  * the only evidence there is that another page exists.
213
258
  */
214
259
  list: async (chatId, options = {}) => {
215
260
  const limit = options.limit ?? 20;
261
+ const { before, after } = options;
262
+ if (after !== undefined) {
263
+ const found = this.#offline
264
+ ? this.#recorded(this.#cache?.messages.window(chatId, after, 0, limit + 1), "messages")
265
+ : await this.#history(chatId, { from: after, backward: 0, forward: limit + 1 });
266
+ const later = found.filter((message) => Date.parse(message.timestamp) > after);
267
+ return { items: later.slice(0, limit), hasMore: later.length > limit };
268
+ }
216
269
  if (this.#offline) {
217
- const stored = this.#recorded(this.#cache?.messages.read(chatId, limit, ANY_AGE), "messages");
218
- return { items: stored, hasMore: stored.length >= limit };
270
+ const cache = this.#cache;
271
+ const stored = before === undefined
272
+ ? cache?.messages.read(chatId, limit, ANY_AGE)
273
+ : cache?.messages.window(chatId, before, limit, 0);
274
+ const items = this.#recorded(stored, "messages");
275
+ return { items, hasMore: items.length >= limit };
219
276
  }
220
- const messages = await this.#history(chatId, { from: options.before ?? Date.now(), backward: limit, forward: 0 });
277
+ const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 });
221
278
  return { items: messages, hasMore: messages.length >= limit };
222
279
  },
223
280
  /**
@@ -226,34 +283,77 @@ export class MaxClient {
226
283
  *
227
284
  * Measured 2026-09-22: from a message's own time, `backward: n` answers n messages ending with
228
285
  * it and `forward: n` the n after it. Its time comes from its id, so no stored copy is needed.
286
+ * Correction 2026-09-23: that holds with `backward` above zero; with `backward: 0`, `forward`
287
+ * starts with the message itself (`messages.list` with `after` depends on the difference).
229
288
  *
230
289
  * ⚠ **A message that is gone is refused, not replaced by its neighbour** — MAX answers with
231
290
  * whatever is nearest, and showing that as the message asked for would be a quiet lie.
232
291
  */
233
- around: async (chatId, messageId, { before = 0, after = 0 } = {}) => {
292
+ around: async (chatId, messageId, { before = 0, after = 0, reactions = true } = {}) => {
234
293
  const time = timeOfMessageId(messageId);
235
294
  if (time === undefined)
236
295
  throw new CliError("validation_error", `"${messageId}" is not a message id`);
237
296
  const found = this.#offline
238
297
  ? (this.#cache?.messages.window(chatId, time, before + 1, after) ?? [])
239
- : await this.#history(chatId, { from: time, backward: before + 1, forward: after });
298
+ : await this.#history(chatId, { from: time, backward: before + 1, forward: after }, { reactions });
240
299
  if (!found.some((message) => message.id === messageId)) {
241
300
  throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
242
301
  }
243
302
  return found.map((message) => (message.id === messageId ? { ...message, anchor: true } : message));
244
303
  },
245
304
  /**
246
- * Turns what `--before` was given into a moment.
305
+ * **Where each attachment of one message can be downloaded from.** A photo and an audio carry
306
+ * their link; a file and a video carry only an id, and MAX answers the link for it (measured
307
+ * 2026-09-23). A video is taken as its largest MP4 — the streaming renditions are playlists,
308
+ * not a file. An attachment with no link to give is left out and named in `skipped`.
309
+ */
310
+ links: async (chatId, messageId) => {
311
+ if (this.#offline)
312
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot download");
313
+ const [message] = await this.messages.around(chatId, messageId, { reactions: false });
314
+ if (!message)
315
+ throw new CliError("not_found", `no message ${messageId} in chat ${chatId}`);
316
+ const links = [];
317
+ const skipped = [];
318
+ for (const attachment of message.attachments) {
319
+ const { kind, name } = attachment;
320
+ if (attachment.fileId) {
321
+ const answer = await this.#wire.attachments.file({ chatId, messageId, fileId: attachment.fileId });
322
+ const url = typeof answer.url === "string" ? answer.url : undefined;
323
+ if (url)
324
+ links.push({ kind, url, ...(name ? { name } : {}), ...(answer.unsafe === true ? { unsafe: true } : {}) });
325
+ else
326
+ skipped.push(kind);
327
+ }
328
+ else if (attachment.videoId) {
329
+ const answer = await this.#wire.attachments.video({ chatId, messageId, videoId: attachment.videoId });
330
+ const url = largestMp4(answer);
331
+ if (url)
332
+ links.push({ kind, url });
333
+ else
334
+ skipped.push(kind);
335
+ }
336
+ else if (attachment.url && (kind === "photo" || kind === "audio")) {
337
+ links.push({ kind, url: attachment.url });
338
+ }
339
+ else {
340
+ skipped.push(kind);
341
+ }
342
+ }
343
+ return { links, skipped };
344
+ },
345
+ /**
346
+ * Turns what `--before` or `--after` was given into a moment.
247
347
  *
248
348
  * **ISO 8601 is a time; a bare integer is a message id**, and a message id carries its own time
249
349
  * (`timeOfMessageId`). Deciding between the two by length would be a trap that fires the first
250
350
  * time either changes size, so the rule is what the string looks like.
251
351
  */
252
- before: (reference) => {
352
+ moment: (reference, flag = "--before") => {
253
353
  const wanted = reference.trim();
254
354
  const time = /^\d+$/.test(wanted) ? timeOfMessageId(wanted) : Date.parse(wanted);
255
355
  if (time === undefined || Number.isNaN(time)) {
256
- throw new CliError("validation_error", `--before takes a message id or an ISO 8601 time, not "${wanted}"`);
356
+ throw new CliError("validation_error", `${flag} takes a message id or an ISO 8601 time, not "${wanted}"`);
257
357
  }
258
358
  return time;
259
359
  },
@@ -273,38 +373,170 @@ export class MaxClient {
273
373
  send: async (chatId, text, options = {}) => {
274
374
  if (this.#offline)
275
375
  throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot send");
276
- await this.#connectOnce();
277
- const session = this.#session();
376
+ // Before connecting: a refused send never opens a socket when the chat was given as an id.
377
+ try {
378
+ this.#sends?.check(chatId);
379
+ }
380
+ catch (error) {
381
+ this.#sends?.record({ chatId, outcome: "refused", errorCode: asCliError(error).code });
382
+ throw error;
383
+ }
278
384
  const cid = options.cid ?? this.#nextCid();
279
- const request = {
280
- chatId,
281
- message: { text, cid, elements: [], attaches: [] },
282
- notify: options.notify ?? true,
283
- };
284
- let answer;
285
385
  try {
286
- answer = await this.#wire.messages.send(request);
386
+ const sent = await this.#deliver(chatId, text, cid, options);
387
+ this.#sends?.record({ chatId, outcome: "sent", messageId: sent.id, cid, length: text.length });
388
+ return sent;
287
389
  }
288
390
  catch (error) {
289
391
  const failure = asCliError(error);
290
- if (failure.code !== "timeout" && failure.code !== "network_error")
291
- throw failure;
292
- // No answer came back, so MAX may already have delivered it. Repeating the identical `cid`
293
- // is what makes asking again safe rather than reckless.
294
- try {
295
- answer = await this.#wire.messages.send(request);
296
- }
297
- catch {
298
- throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
299
- `\`max messages send <chat> <text> --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
300
- }
392
+ this.#sends?.record({
393
+ chatId,
394
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
395
+ cid,
396
+ length: text.length,
397
+ errorCode: failure.code,
398
+ });
399
+ throw error;
301
400
  }
302
- // What the cache holds for this chat is now one message short of the truth.
303
- this.#cache?.messages.invalidate(chatId);
304
- const sent = record(answer.message) ?? answer;
305
- return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
401
+ },
402
+ /**
403
+ * Puts one emoji reaction on a message. Not retried: a reaction lost in transit costs a second
404
+ * command, and nothing about it is measured to make a blind repeat safe.
405
+ */
406
+ react: async (chatId, messageId, emoji) => {
407
+ if (this.#offline)
408
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
409
+ try {
410
+ this.#sends?.check(chatId, "reaction");
411
+ }
412
+ catch (error) {
413
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
414
+ throw error;
415
+ }
416
+ try {
417
+ await this.#connectOnce();
418
+ const answer = await this.#wire.messages.react({
419
+ chatId,
420
+ messageId,
421
+ reaction: { reactionType: "EMOJI", id: emoji },
422
+ });
423
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
424
+ return toReactions(record(answer.reactionInfo) ?? {});
425
+ }
426
+ catch (error) {
427
+ this.#sends?.record({
428
+ chatId,
429
+ kind: "reaction",
430
+ outcome: "failed",
431
+ messageId,
432
+ errorCode: asCliError(error).code,
433
+ });
434
+ throw error;
435
+ }
436
+ },
437
+ };
438
+ inbox = {
439
+ /**
440
+ * Other people's unread messages, as MAX counts them: for each chat with a count, its newest
441
+ * that many. Reading changes nothing — no `CHAT_MARK` — so the same messages come back until
442
+ * they are read somewhere else. That is right for a person and wrong for a scheduled run,
443
+ * which is what `since` is for.
444
+ */
445
+ unread: async ({ limit }) => {
446
+ const chats = (await this.chats.list()).items;
447
+ const waiting = byRecency(chats.filter((chat) => (chat.unreadCount ?? 0) > 0));
448
+ const { read, skipped } = capped(waiting);
449
+ const found = [];
450
+ for (const { id, title, kind, unreadCount } of read) {
451
+ const count = unreadCount ?? 0;
452
+ const wanted = Math.min(count, limit);
453
+ const { items } = await this.messages.list(id, { limit: wanted });
454
+ const theirs = items.slice(-wanted).filter((message) => message.outgoing !== true);
455
+ if (theirs.length > 0)
456
+ found.push({ id, title, kind, unreadCount, messages: theirs, more: count > limit });
457
+ }
458
+ return { mode: "unread", chats: found, skipped, partial: !this.#cache && chats.length >= LOGIN_CHATS };
459
+ },
460
+ /**
461
+ * Other people's messages in every chat that changed after `since`.
462
+ *
463
+ * A chat changed if its last message is later than `since`; the chat list says so without a
464
+ * request, and with a store it covers every chat, not only the ones this login named. Each
465
+ * changed chat then costs one history read of its newest `limit` — the newest, because the
466
+ * reader wants what just arrived, and one request rather than paging forward to reach it.
467
+ *
468
+ * **Everything is cut at the chat list's newest message**, the snapshot this login took. The
469
+ * reads run one after another, so a chat read early can gain a message while a later one is
470
+ * read; had the saved point followed the later read, that message would sit behind it and
471
+ * never show. Anything newer than the snapshot waits for the next run and shows once there.
472
+ */
473
+ since: async ({ since, limit }) => {
474
+ const chats = (await this.chats.list()).items;
475
+ const changed = byRecency(chats.filter((chat) => chat.lastMessageAt !== null && Date.parse(chat.lastMessageAt) > since));
476
+ const cut = Math.max(since, ...changed.map((chat) => Date.parse(chat.lastMessageAt ?? "")));
477
+ const { read, skipped } = capped(changed);
478
+ let until = since;
479
+ const found = [];
480
+ for (const { id, title, kind, unreadCount } of read) {
481
+ const { items } = await this.messages.list(id, { limit });
482
+ const fresh = items.filter((message) => {
483
+ const time = Date.parse(message.timestamp);
484
+ return time > since && time <= cut;
485
+ });
486
+ for (const message of fresh)
487
+ until = Math.max(until, Date.parse(message.timestamp));
488
+ const theirs = fresh.filter((message) => message.outgoing !== true);
489
+ if (theirs.length > 0)
490
+ found.push({ id, title, kind, unreadCount, messages: theirs, more: fresh.length >= limit });
491
+ }
492
+ return {
493
+ mode: "new",
494
+ since: new Date(since).toISOString(),
495
+ until: new Date(until).toISOString(),
496
+ chats: found,
497
+ skipped,
498
+ partial: !this.#cache && chats.length >= LOGIN_CHATS && changed.length === chats.length,
499
+ };
306
500
  },
307
501
  };
502
+ async #deliver(chatId, text, cid, options) {
503
+ await this.#connectOnce();
504
+ const session = this.#session();
505
+ const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: [] };
506
+ const request = {
507
+ chatId,
508
+ message: {
509
+ text: plain,
510
+ cid,
511
+ elements: markup,
512
+ attaches: [],
513
+ ...(options.replyTo ? { link: { type: "REPLY", messageId: options.replyTo } } : {}),
514
+ },
515
+ notify: options.notify ?? true,
516
+ };
517
+ let answer;
518
+ try {
519
+ answer = await this.#wire.messages.send(request);
520
+ }
521
+ catch (error) {
522
+ const failure = asCliError(error);
523
+ if (failure.code !== "timeout" && failure.code !== "network_error")
524
+ throw failure;
525
+ // No answer came back, so MAX may already have delivered it. Repeating the identical `cid`
526
+ // is what makes asking again safe rather than reckless.
527
+ try {
528
+ answer = await this.#wire.messages.send(request);
529
+ }
530
+ catch {
531
+ throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
532
+ `\`max messages send <chat> <text> --cid ${cid}\` repeats the attempt without risking a second copy`, { cid });
533
+ }
534
+ }
535
+ // What the cache holds for this chat is now one message short of the truth.
536
+ this.#cache?.messages.invalidate(chatId);
537
+ const sent = record(answer.message) ?? answer;
538
+ return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
539
+ }
308
540
  /**
309
541
  * Opens the connection and logs in with the stored token, or with one offered for trial.
310
542
  *
@@ -464,7 +696,7 @@ export class MaxClient {
464
696
  * public for the one command that must reach MAX to mean anything — starting a session.
465
697
  */
466
698
  /** `interactive: false` and never `CHAT_MARK`: reading history must not mark anything read (§19). */
467
- async #history(chatId, window) {
699
+ async #history(chatId, window, { reactions = true } = {}) {
468
700
  await this.#connectOnce();
469
701
  const session = this.#session();
470
702
  const answer = await this.#wire.chats.history({
@@ -480,7 +712,24 @@ export class MaxClient {
480
712
  const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
481
713
  const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
482
714
  this.#cache?.messages.write(chatId, messages);
483
- return messages;
715
+ return reactions ? this.#withReactions(chatId, messages) : messages;
716
+ }
717
+ /** One request per page: history carries no reactions (measured 2026-09-23). */
718
+ async #withReactions(chatId, messages) {
719
+ if (messages.length === 0)
720
+ return messages;
721
+ try {
722
+ const answer = await this.#wire.messages.reactions({ chatId, messageIds: messages.map((message) => message.id) });
723
+ const byId = record(answer.messagesReactions) ?? {};
724
+ return messages.map((message) => {
725
+ const raw = record(byId[message.id]);
726
+ return { ...message, reactions: raw ? toReactions(raw) : { counts: [], mine: null, total: 0 } };
727
+ });
728
+ }
729
+ catch (error) {
730
+ this.#warn(`reactions are not shown: they could not be read (${reasonOf(error)})`);
731
+ return messages;
732
+ }
484
733
  }
485
734
  /**
486
735
  * **A group member who is not a contact arrives as a bare id** — the login names contacts only,
@@ -523,6 +772,17 @@ export class MaxClient {
523
772
  forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
524
773
  }));
525
774
  }
775
+ /** INIT with the profile's own device, which is the device MAX then issues the token to (bite 8). */
776
+ async #beforeLogin(flow) {
777
+ try {
778
+ await this.#connection.open();
779
+ }
780
+ catch (error) {
781
+ throw asCliError(error);
782
+ }
783
+ await this.#wire.session.init({ userAgent: WEB_USER_AGENT, deviceId: this.#store.readState().deviceId });
784
+ return await flow();
785
+ }
526
786
  async #connectOnce() {
527
787
  if (!this.#login)
528
788
  await this.connect();
@@ -669,6 +929,10 @@ export class MaxClient {
669
929
  return this.#login;
670
930
  }
671
931
  }
932
+ const largestMp4 = (answer) => Object.entries(answer)
933
+ .map(([key, value]) => ({ height: /^MP4_(\d+)$/.exec(key)?.[1], value }))
934
+ .filter((entry) => entry.height !== undefined && typeof entry.value === "string")
935
+ .sort((a, b) => Number(b.height) - Number(a.height))[0]?.value;
672
936
  /**
673
937
  * **The shortest search the index can answer is three characters**, and below that it returns
674
938
  * nothing rather than complaining (measured 2026-09-22). An empty list reads as "no matches",
@@ -695,6 +959,29 @@ const checkedQuery = (query) => {
695
959
  * names itself rather than as a wrong answer.
696
960
  */
697
961
  const CONTACT_INFO_BATCH = 100;
962
+ /**
963
+ * When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
964
+ * low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
965
+ * `undefined` for anything that is not a message id.
966
+ */
967
+ export const timeOfMessageId = (id) => {
968
+ if (!/^\d{10,20}$/.test(id))
969
+ return undefined;
970
+ const time = Number(BigInt(id) >> 16n);
971
+ return Number.isSafeInteger(time) && time > 0 ? time : undefined;
972
+ };
973
+ /**
974
+ * **At most this many history reads per `max inbox`.** The official client reads a chat's history
975
+ * when a person opens it; twenty in one burst is already more than a person does (§34). A personal
976
+ * account rarely has that many chats change between two checks, and the rest are named, not lost.
977
+ */
978
+ const INBOX_CHATS = 20;
979
+ /** Newest first — the chats a reader most likely came for are read before the cap. */
980
+ const byRecency = (chats) => chats.toSorted((a, b) => Date.parse(b.lastMessageAt ?? "") - Date.parse(a.lastMessageAt ?? ""));
981
+ const capped = (chats) => ({
982
+ read: chats.slice(0, INBOX_CHATS),
983
+ skipped: chats.slice(INBOX_CHATS).map(({ id, title, lastMessageAt }) => ({ id, title, lastMessageAt })),
984
+ });
698
985
  const isPresent = (value) => value !== null && value !== undefined;
699
986
  const needsName = (message) => message.senderId !== null && message.senderName === null && message.outgoing !== true;
700
987
  const batched = (items, size) => {
@@ -717,9 +1004,10 @@ const batched = (items, size) => {
717
1004
  * The alternative was to let a filter only work when a cache happened to exist, which makes the
718
1005
  * answer depend on something the person never asked about.
719
1006
  */
720
- const matching = (chats, query, kind) => {
1007
+ const matching = (chats, { query, kind, unread }) => {
721
1008
  const needle = query?.toLocaleLowerCase();
722
1009
  return chats.filter((chat) => (kind === undefined || chat.kind === kind) &&
1010
+ (!unread || (chat.unreadCount ?? 0) > 0) &&
723
1011
  (needle === undefined || (chat.title ?? "").toLocaleLowerCase().includes(needle)));
724
1012
  };
725
1013
  /** As `matching`, over the two columns `people_fts` indexes. */