@wirecat/max-cli 0.0.0 → 0.43.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 (546) hide show
  1. package/LICENSE +204 -0
  2. package/README.md +531 -0
  3. package/dist/adapter/max-adapter.d.ts +17 -0
  4. package/dist/adapter/max-adapter.d.ts.map +1 -0
  5. package/dist/adapter/max-adapter.js +364 -0
  6. package/dist/adapter/max-adapter.js.map +1 -0
  7. package/dist/app.d.ts +3 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +9 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/bin/max.d.ts +3 -0
  12. package/dist/bin/max.d.ts.map +1 -0
  13. package/dist/bin/max.js +20 -0
  14. package/dist/bin/max.js.map +1 -0
  15. package/dist/bot/adapter.d.ts +25 -0
  16. package/dist/bot/adapter.d.ts.map +1 -0
  17. package/dist/bot/adapter.js +404 -0
  18. package/dist/bot/adapter.js.map +1 -0
  19. package/dist/bot/auth.d.ts +13 -0
  20. package/dist/bot/auth.d.ts.map +1 -0
  21. package/dist/bot/auth.js +9 -0
  22. package/dist/bot/auth.js.map +1 -0
  23. package/dist/bot/client.d.ts +26 -0
  24. package/dist/bot/client.d.ts.map +1 -0
  25. package/dist/bot/client.js +105 -0
  26. package/dist/bot/client.js.map +1 -0
  27. package/dist/bot/generated/manifest.d.ts +3 -0
  28. package/dist/bot/generated/manifest.d.ts.map +1 -0
  29. package/dist/bot/generated/manifest.js +1232 -0
  30. package/dist/bot/generated/manifest.js.map +1 -0
  31. package/dist/bot/generated/schemas.d.ts +271 -0
  32. package/dist/bot/generated/schemas.d.ts.map +1 -0
  33. package/dist/bot/generated/schemas.js +869 -0
  34. package/dist/bot/generated/schemas.js.map +1 -0
  35. package/dist/bot/generated/types.d.ts +1088 -0
  36. package/dist/bot/generated/types.d.ts.map +1 -0
  37. package/dist/bot/generated/types.js +5 -0
  38. package/dist/bot/generated/types.js.map +1 -0
  39. package/dist/bot/input.d.ts +6 -0
  40. package/dist/bot/input.d.ts.map +1 -0
  41. package/dist/bot/input.js +6 -0
  42. package/dist/bot/input.js.map +1 -0
  43. package/dist/bot/joins.d.ts +26 -0
  44. package/dist/bot/joins.d.ts.map +1 -0
  45. package/dist/bot/joins.js +56 -0
  46. package/dist/bot/joins.js.map +1 -0
  47. package/dist/bot/keep.d.ts +25 -0
  48. package/dist/bot/keep.d.ts.map +1 -0
  49. package/dist/bot/keep.js +98 -0
  50. package/dist/bot/keep.js.map +1 -0
  51. package/dist/bot/map.d.ts +14 -0
  52. package/dist/bot/map.d.ts.map +1 -0
  53. package/dist/bot/map.js +101 -0
  54. package/dist/bot/map.js.map +1 -0
  55. package/dist/bot/mcp-tools.d.ts +4 -0
  56. package/dist/bot/mcp-tools.d.ts.map +1 -0
  57. package/dist/bot/mcp-tools.js +78 -0
  58. package/dist/bot/mcp-tools.js.map +1 -0
  59. package/dist/bot/permissions.d.ts +14 -0
  60. package/dist/bot/permissions.d.ts.map +1 -0
  61. package/dist/bot/permissions.js +88 -0
  62. package/dist/bot/permissions.js.map +1 -0
  63. package/dist/bot/registry.d.ts +11 -0
  64. package/dist/bot/registry.d.ts.map +1 -0
  65. package/dist/bot/registry.js +13 -0
  66. package/dist/bot/registry.js.map +1 -0
  67. package/dist/bot/russian-trusted-root.d.ts +8 -0
  68. package/dist/bot/russian-trusted-root.d.ts.map +1 -0
  69. package/dist/bot/russian-trusted-root.js +41 -0
  70. package/dist/bot/russian-trusted-root.js.map +1 -0
  71. package/dist/bot/transport.d.ts +46 -0
  72. package/dist/bot/transport.d.ts.map +1 -0
  73. package/dist/bot/transport.js +252 -0
  74. package/dist/bot/transport.js.map +1 -0
  75. package/dist/bot/uploads.d.ts +42 -0
  76. package/dist/bot/uploads.d.ts.map +1 -0
  77. package/dist/bot/uploads.js +145 -0
  78. package/dist/bot/uploads.js.map +1 -0
  79. package/dist/client.d.ts +650 -0
  80. package/dist/client.d.ts.map +1 -0
  81. package/dist/client.js +2836 -0
  82. package/dist/client.js.map +1 -0
  83. package/dist/commands/account.d.ts +3 -0
  84. package/dist/commands/account.d.ts.map +1 -0
  85. package/dist/commands/account.js +4 -0
  86. package/dist/commands/account.js.map +1 -0
  87. package/dist/commands/bot-comments.d.ts +3 -0
  88. package/dist/commands/bot-comments.d.ts.map +1 -0
  89. package/dist/commands/bot-comments.js +74 -0
  90. package/dist/commands/bot-comments.js.map +1 -0
  91. package/dist/commands/bot-context.d.ts +38 -0
  92. package/dist/commands/bot-context.d.ts.map +1 -0
  93. package/dist/commands/bot-context.js +121 -0
  94. package/dist/commands/bot-context.js.map +1 -0
  95. package/dist/commands/bot-members.d.ts +14 -0
  96. package/dist/commands/bot-members.d.ts.map +1 -0
  97. package/dist/commands/bot-members.js +64 -0
  98. package/dist/commands/bot-members.js.map +1 -0
  99. package/dist/commands/bot-messenger.d.ts +10 -0
  100. package/dist/commands/bot-messenger.d.ts.map +1 -0
  101. package/dist/commands/bot-messenger.js +101 -0
  102. package/dist/commands/bot-messenger.js.map +1 -0
  103. package/dist/commands/bot-reads.d.ts +4 -0
  104. package/dist/commands/bot-reads.d.ts.map +1 -0
  105. package/dist/commands/bot-reads.js +13 -0
  106. package/dist/commands/bot-reads.js.map +1 -0
  107. package/dist/commands/bot-sends.d.ts +13 -0
  108. package/dist/commands/bot-sends.d.ts.map +1 -0
  109. package/dist/commands/bot-sends.js +103 -0
  110. package/dist/commands/bot-sends.js.map +1 -0
  111. package/dist/commands/bot-setup.d.ts +3 -0
  112. package/dist/commands/bot-setup.d.ts.map +1 -0
  113. package/dist/commands/bot-setup.js +20 -0
  114. package/dist/commands/bot-setup.js.map +1 -0
  115. package/dist/commands/bot.d.ts +3 -0
  116. package/dist/commands/bot.d.ts.map +1 -0
  117. package/dist/commands/bot.js +60 -0
  118. package/dist/commands/bot.js.map +1 -0
  119. package/dist/commands/calls.d.ts +3 -0
  120. package/dist/commands/calls.d.ts.map +1 -0
  121. package/dist/commands/calls.js +4 -0
  122. package/dist/commands/calls.js.map +1 -0
  123. package/dist/commands/chats.d.ts +3 -0
  124. package/dist/commands/chats.d.ts.map +1 -0
  125. package/dist/commands/chats.js +46 -0
  126. package/dist/commands/chats.js.map +1 -0
  127. package/dist/commands/commands.d.ts +2 -0
  128. package/dist/commands/commands.d.ts.map +1 -0
  129. package/dist/commands/commands.js +4 -0
  130. package/dist/commands/commands.js.map +1 -0
  131. package/dist/commands/complete.d.ts +2 -0
  132. package/dist/commands/complete.d.ts.map +1 -0
  133. package/dist/commands/complete.js +34 -0
  134. package/dist/commands/complete.js.map +1 -0
  135. package/dist/commands/config.d.ts +3 -0
  136. package/dist/commands/config.d.ts.map +1 -0
  137. package/dist/commands/config.js +170 -0
  138. package/dist/commands/config.js.map +1 -0
  139. package/dist/commands/contacts.d.ts +10 -0
  140. package/dist/commands/contacts.d.ts.map +1 -0
  141. package/dist/commands/contacts.js +80 -0
  142. package/dist/commands/contacts.js.map +1 -0
  143. package/dist/commands/context.d.ts +126 -0
  144. package/dist/commands/context.d.ts.map +1 -0
  145. package/dist/commands/context.js +137 -0
  146. package/dist/commands/context.js.map +1 -0
  147. package/dist/commands/doctor.d.ts +24 -0
  148. package/dist/commands/doctor.d.ts.map +1 -0
  149. package/dist/commands/doctor.js +308 -0
  150. package/dist/commands/doctor.js.map +1 -0
  151. package/dist/commands/mcp.d.ts +36 -0
  152. package/dist/commands/mcp.d.ts.map +1 -0
  153. package/dist/commands/mcp.js +214 -0
  154. package/dist/commands/mcp.js.map +1 -0
  155. package/dist/commands/messages.d.ts +3 -0
  156. package/dist/commands/messages.d.ts.map +1 -0
  157. package/dist/commands/messages.js +105 -0
  158. package/dist/commands/messages.js.map +1 -0
  159. package/dist/commands/models.d.ts +3 -0
  160. package/dist/commands/models.d.ts.map +1 -0
  161. package/dist/commands/models.js +4 -0
  162. package/dist/commands/models.js.map +1 -0
  163. package/dist/commands/paging.d.ts +59 -0
  164. package/dist/commands/paging.d.ts.map +1 -0
  165. package/dist/commands/paging.js +77 -0
  166. package/dist/commands/paging.js.map +1 -0
  167. package/dist/commands/recipients.d.ts +9 -0
  168. package/dist/commands/recipients.d.ts.map +1 -0
  169. package/dist/commands/recipients.js +74 -0
  170. package/dist/commands/recipients.js.map +1 -0
  171. package/dist/commands/runs.d.ts +2 -0
  172. package/dist/commands/runs.d.ts.map +1 -0
  173. package/dist/commands/runs.js +4 -0
  174. package/dist/commands/runs.js.map +1 -0
  175. package/dist/commands/sends.d.ts +3 -0
  176. package/dist/commands/sends.d.ts.map +1 -0
  177. package/dist/commands/sends.js +4 -0
  178. package/dist/commands/sends.js.map +1 -0
  179. package/dist/commands/serve-members.d.ts +14 -0
  180. package/dist/commands/serve-members.d.ts.map +1 -0
  181. package/dist/commands/serve-members.js +54 -0
  182. package/dist/commands/serve-members.js.map +1 -0
  183. package/dist/commands/serve-stems.d.ts +9 -0
  184. package/dist/commands/serve-stems.d.ts.map +1 -0
  185. package/dist/commands/serve-stems.js +26 -0
  186. package/dist/commands/serve-stems.js.map +1 -0
  187. package/dist/commands/serve.d.ts +10 -0
  188. package/dist/commands/serve.d.ts.map +1 -0
  189. package/dist/commands/serve.js +79 -0
  190. package/dist/commands/serve.js.map +1 -0
  191. package/dist/commands/server.d.ts +16 -0
  192. package/dist/commands/server.d.ts.map +1 -0
  193. package/dist/commands/server.js +51 -0
  194. package/dist/commands/server.js.map +1 -0
  195. package/dist/commands/session.d.ts +15 -0
  196. package/dist/commands/session.d.ts.map +1 -0
  197. package/dist/commands/session.js +169 -0
  198. package/dist/commands/session.js.map +1 -0
  199. package/dist/commands/setup.d.ts +3 -0
  200. package/dist/commands/setup.d.ts.map +1 -0
  201. package/dist/commands/setup.js +168 -0
  202. package/dist/commands/setup.js.map +1 -0
  203. package/dist/commands/skill.d.ts +3 -0
  204. package/dist/commands/skill.d.ts.map +1 -0
  205. package/dist/commands/skill.js +7 -0
  206. package/dist/commands/skill.js.map +1 -0
  207. package/dist/commands/stickers.d.ts +3 -0
  208. package/dist/commands/stickers.d.ts.map +1 -0
  209. package/dist/commands/stickers.js +4 -0
  210. package/dist/commands/stickers.js.map +1 -0
  211. package/dist/commands/upgrade.d.ts +2 -0
  212. package/dist/commands/upgrade.d.ts.map +1 -0
  213. package/dist/commands/upgrade.js +14 -0
  214. package/dist/commands/upgrade.js.map +1 -0
  215. package/dist/commands/watch.d.ts +27 -0
  216. package/dist/commands/watch.d.ts.map +1 -0
  217. package/dist/commands/watch.js +105 -0
  218. package/dist/commands/watch.js.map +1 -0
  219. package/dist/config.d.ts +385 -0
  220. package/dist/config.d.ts.map +1 -0
  221. package/dist/config.js +372 -0
  222. package/dist/config.js.map +1 -0
  223. package/dist/deadline.d.ts +2 -0
  224. package/dist/deadline.d.ts.map +1 -0
  225. package/dist/deadline.js +2 -0
  226. package/dist/deadline.js.map +1 -0
  227. package/dist/diagnose.d.ts +143 -0
  228. package/dist/diagnose.d.ts.map +1 -0
  229. package/dist/diagnose.js +183 -0
  230. package/dist/diagnose.js.map +1 -0
  231. package/dist/domain/map.d.ts +65 -0
  232. package/dist/domain/map.d.ts.map +1 -0
  233. package/dist/domain/map.js +431 -0
  234. package/dist/domain/map.js.map +1 -0
  235. package/dist/domain/models.d.ts +238 -0
  236. package/dist/domain/models.d.ts.map +1 -0
  237. package/dist/domain/models.js +17 -0
  238. package/dist/domain/models.js.map +1 -0
  239. package/dist/download.d.ts +38 -0
  240. package/dist/download.d.ts.map +1 -0
  241. package/dist/download.js +199 -0
  242. package/dist/download.js.map +1 -0
  243. package/dist/format-markdown.d.ts +13 -0
  244. package/dist/format-markdown.d.ts.map +1 -0
  245. package/dist/format-markdown.js +241 -0
  246. package/dist/format-markdown.js.map +1 -0
  247. package/dist/generated/client.generated.d.ts +95 -0
  248. package/dist/generated/client.generated.d.ts.map +1 -0
  249. package/dist/generated/client.generated.js +91 -0
  250. package/dist/generated/client.generated.js.map +1 -0
  251. package/dist/generated/opcodes.generated.d.ts +76 -0
  252. package/dist/generated/opcodes.generated.d.ts.map +1 -0
  253. package/dist/generated/opcodes.generated.js +76 -0
  254. package/dist/generated/opcodes.generated.js.map +1 -0
  255. package/dist/generated/operations.generated.d.ts +502 -0
  256. package/dist/generated/operations.generated.d.ts.map +1 -0
  257. package/dist/generated/operations.generated.js +72 -0
  258. package/dist/generated/operations.generated.js.map +1 -0
  259. package/dist/inbox-point.d.ts +3 -0
  260. package/dist/inbox-point.d.ts.map +1 -0
  261. package/dist/inbox-point.js +17 -0
  262. package/dist/inbox-point.js.map +1 -0
  263. package/dist/install/postinstall.d.ts +8 -0
  264. package/dist/install/postinstall.d.ts.map +1 -0
  265. package/dist/install/postinstall.js +62 -0
  266. package/dist/install/postinstall.js.map +1 -0
  267. package/dist/install.d.ts +43 -0
  268. package/dist/install.d.ts.map +1 -0
  269. package/dist/install.js +142 -0
  270. package/dist/install.js.map +1 -0
  271. package/dist/markdown.d.ts +13 -0
  272. package/dist/markdown.d.ts.map +1 -0
  273. package/dist/markdown.js +6 -0
  274. package/dist/markdown.js.map +1 -0
  275. package/dist/mcp/instructions.d.ts +13 -0
  276. package/dist/mcp/instructions.d.ts.map +1 -0
  277. package/dist/mcp/instructions.js +16 -0
  278. package/dist/mcp/instructions.js.map +1 -0
  279. package/dist/mcp/legacy-check.d.ts +12 -0
  280. package/dist/mcp/legacy-check.d.ts.map +1 -0
  281. package/dist/mcp/legacy-check.js +44 -0
  282. package/dist/mcp/legacy-check.js.map +1 -0
  283. package/dist/mcp/photo.d.ts +9 -0
  284. package/dist/mcp/photo.d.ts.map +1 -0
  285. package/dist/mcp/photo.js +38 -0
  286. package/dist/mcp/photo.js.map +1 -0
  287. package/dist/mcp/prompts.d.ts +7 -0
  288. package/dist/mcp/prompts.d.ts.map +1 -0
  289. package/dist/mcp/prompts.js +100 -0
  290. package/dist/mcp/prompts.js.map +1 -0
  291. package/dist/mcp/resources.d.ts +15 -0
  292. package/dist/mcp/resources.d.ts.map +1 -0
  293. package/dist/mcp/resources.js +56 -0
  294. package/dist/mcp/resources.js.map +1 -0
  295. package/dist/mcp/server.d.ts +31 -0
  296. package/dist/mcp/server.d.ts.map +1 -0
  297. package/dist/mcp/server.js +116 -0
  298. package/dist/mcp/server.js.map +1 -0
  299. package/dist/mcp/session.d.ts +30 -0
  300. package/dist/mcp/session.d.ts.map +1 -0
  301. package/dist/mcp/session.js +109 -0
  302. package/dist/mcp/session.js.map +1 -0
  303. package/dist/mcp/shared.d.ts +14 -0
  304. package/dist/mcp/shared.d.ts.map +1 -0
  305. package/dist/mcp/shared.js +46 -0
  306. package/dist/mcp/shared.js.map +1 -0
  307. package/dist/mcp/tools.d.ts +30 -0
  308. package/dist/mcp/tools.d.ts.map +1 -0
  309. package/dist/mcp/tools.js +248 -0
  310. package/dist/mcp/tools.js.map +1 -0
  311. package/dist/messenger.d.ts +15 -0
  312. package/dist/messenger.d.ts.map +1 -0
  313. package/dist/messenger.js +149 -0
  314. package/dist/messenger.js.map +1 -0
  315. package/dist/moderation/check.d.ts +103 -0
  316. package/dist/moderation/check.d.ts.map +1 -0
  317. package/dist/moderation/check.js +273 -0
  318. package/dist/moderation/check.js.map +1 -0
  319. package/dist/moderation/points.d.ts +8 -0
  320. package/dist/moderation/points.d.ts.map +1 -0
  321. package/dist/moderation/points.js +21 -0
  322. package/dist/moderation/points.js.map +1 -0
  323. package/dist/moderation/rules.d.ts +7 -0
  324. package/dist/moderation/rules.d.ts.map +1 -0
  325. package/dist/moderation/rules.js +24 -0
  326. package/dist/moderation/rules.js.map +1 -0
  327. package/dist/online.d.ts +23 -0
  328. package/dist/online.d.ts.map +1 -0
  329. package/dist/online.js +87 -0
  330. package/dist/online.js.map +1 -0
  331. package/dist/output.d.ts +2 -0
  332. package/dist/output.d.ts.map +1 -0
  333. package/dist/output.js +2 -0
  334. package/dist/output.js.map +1 -0
  335. package/dist/permissions.d.ts +20 -0
  336. package/dist/permissions.d.ts.map +1 -0
  337. package/dist/permissions.js +76 -0
  338. package/dist/permissions.js.map +1 -0
  339. package/dist/profile.d.ts +33 -0
  340. package/dist/profile.d.ts.map +1 -0
  341. package/dist/profile.js +60 -0
  342. package/dist/profile.js.map +1 -0
  343. package/dist/program.d.ts +10 -0
  344. package/dist/program.d.ts.map +1 -0
  345. package/dist/program.js +172 -0
  346. package/dist/program.js.map +1 -0
  347. package/dist/protocol/connection.d.ts +83 -0
  348. package/dist/protocol/connection.d.ts.map +1 -0
  349. package/dist/protocol/connection.js +301 -0
  350. package/dist/protocol/connection.js.map +1 -0
  351. package/dist/protocol/frame.d.ts +55 -0
  352. package/dist/protocol/frame.d.ts.map +1 -0
  353. package/dist/protocol/frame.js +149 -0
  354. package/dist/protocol/frame.js.map +1 -0
  355. package/dist/protocol/lz4.d.ts +15 -0
  356. package/dist/protocol/lz4.d.ts.map +1 -0
  357. package/dist/protocol/lz4.js +157 -0
  358. package/dist/protocol/lz4.js.map +1 -0
  359. package/dist/record.d.ts +41 -0
  360. package/dist/record.d.ts.map +1 -0
  361. package/dist/record.js +104 -0
  362. package/dist/record.js.map +1 -0
  363. package/dist/rendering/messages.d.ts +24 -0
  364. package/dist/rendering/messages.d.ts.map +1 -0
  365. package/dist/rendering/messages.js +172 -0
  366. package/dist/rendering/messages.js.map +1 -0
  367. package/dist/report.d.ts +43 -0
  368. package/dist/report.d.ts.map +1 -0
  369. package/dist/report.js +104 -0
  370. package/dist/report.js.map +1 -0
  371. package/dist/resolve.d.ts +6 -0
  372. package/dist/resolve.d.ts.map +1 -0
  373. package/dist/resolve.js +5 -0
  374. package/dist/resolve.js.map +1 -0
  375. package/dist/sends.d.ts +17 -0
  376. package/dist/sends.d.ts.map +1 -0
  377. package/dist/sends.js +97 -0
  378. package/dist/sends.js.map +1 -0
  379. package/dist/server/lines.d.ts +23 -0
  380. package/dist/server/lines.d.ts.map +1 -0
  381. package/dist/server/lines.js +56 -0
  382. package/dist/server/lines.js.map +1 -0
  383. package/dist/server/replies.d.ts +31 -0
  384. package/dist/server/replies.d.ts.map +1 -0
  385. package/dist/server/replies.js +95 -0
  386. package/dist/server/replies.js.map +1 -0
  387. package/dist/server/server-connection.d.ts +55 -0
  388. package/dist/server/server-connection.d.ts.map +1 -0
  389. package/dist/server/server-connection.js +245 -0
  390. package/dist/server/server-connection.js.map +1 -0
  391. package/dist/server/server.d.ts +88 -0
  392. package/dist/server/server.d.ts.map +1 -0
  393. package/dist/server/server.js +634 -0
  394. package/dist/server/server.js.map +1 -0
  395. package/dist/server/start.d.ts +37 -0
  396. package/dist/server/start.d.ts.map +1 -0
  397. package/dist/server/start.js +102 -0
  398. package/dist/server/start.js.map +1 -0
  399. package/dist/server/subscribe.d.ts +9 -0
  400. package/dist/server/subscribe.d.ts.map +1 -0
  401. package/dist/server/subscribe.js +26 -0
  402. package/dist/server/subscribe.js.map +1 -0
  403. package/dist/session/adopt.d.ts +22 -0
  404. package/dist/session/adopt.d.ts.map +1 -0
  405. package/dist/session/adopt.js +34 -0
  406. package/dist/session/adopt.js.map +1 -0
  407. package/dist/session/browser.d.ts +55 -0
  408. package/dist/session/browser.d.ts.map +1 -0
  409. package/dist/session/browser.js +321 -0
  410. package/dist/session/browser.js.map +1 -0
  411. package/dist/session/handshake.d.ts +57 -0
  412. package/dist/session/handshake.d.ts.map +1 -0
  413. package/dist/session/handshake.js +40 -0
  414. package/dist/session/handshake.js.map +1 -0
  415. package/dist/session/login.d.ts +20 -0
  416. package/dist/session/login.d.ts.map +1 -0
  417. package/dist/session/login.js +66 -0
  418. package/dist/session/login.js.map +1 -0
  419. package/dist/session/prompt.d.ts +2 -0
  420. package/dist/session/prompt.d.ts.map +1 -0
  421. package/dist/session/prompt.js +2 -0
  422. package/dist/session/prompt.js.map +1 -0
  423. package/dist/session/qr-terminal.d.ts +9 -0
  424. package/dist/session/qr-terminal.d.ts.map +1 -0
  425. package/dist/session/qr-terminal.js +34 -0
  426. package/dist/session/qr-terminal.js.map +1 -0
  427. package/dist/session/store.d.ts +76 -0
  428. package/dist/session/store.d.ts.map +1 -0
  429. package/dist/session/store.js +134 -0
  430. package/dist/session/store.js.map +1 -0
  431. package/dist/skill.d.ts +7 -0
  432. package/dist/skill.d.ts.map +1 -0
  433. package/dist/skill.js +8 -0
  434. package/dist/skill.js.map +1 -0
  435. package/dist/spec/check.d.ts +16 -0
  436. package/dist/spec/check.d.ts.map +1 -0
  437. package/dist/spec/check.js +44 -0
  438. package/dist/spec/check.js.map +1 -0
  439. package/dist/spec/define.d.ts +108 -0
  440. package/dist/spec/define.d.ts.map +1 -0
  441. package/dist/spec/define.js +47 -0
  442. package/dist/spec/define.js.map +1 -0
  443. package/dist/spec/guards.d.ts +16 -0
  444. package/dist/spec/guards.d.ts.map +1 -0
  445. package/dist/spec/guards.js +30 -0
  446. package/dist/spec/guards.js.map +1 -0
  447. package/dist/spec/identity.d.ts +57 -0
  448. package/dist/spec/identity.d.ts.map +1 -0
  449. package/dist/spec/identity.js +53 -0
  450. package/dist/spec/identity.js.map +1 -0
  451. package/dist/spec/index.d.ts +15 -0
  452. package/dist/spec/index.d.ts.map +1 -0
  453. package/dist/spec/index.js +84 -0
  454. package/dist/spec/index.js.map +1 -0
  455. package/dist/spec/operations/account.d.ts +39 -0
  456. package/dist/spec/operations/account.d.ts.map +1 -0
  457. package/dist/spec/operations/account.js +103 -0
  458. package/dist/spec/operations/account.js.map +1 -0
  459. package/dist/spec/operations/assets.d.ts +17 -0
  460. package/dist/spec/operations/assets.d.ts.map +1 -0
  461. package/dist/spec/operations/assets.js +43 -0
  462. package/dist/spec/operations/assets.js.map +1 -0
  463. package/dist/spec/operations/attachments.d.ts +15 -0
  464. package/dist/spec/operations/attachments.d.ts.map +1 -0
  465. package/dist/spec/operations/attachments.js +40 -0
  466. package/dist/spec/operations/attachments.js.map +1 -0
  467. package/dist/spec/operations/banners.d.ts +7 -0
  468. package/dist/spec/operations/banners.d.ts.map +1 -0
  469. package/dist/spec/operations/banners.js +16 -0
  470. package/dist/spec/operations/banners.js.map +1 -0
  471. package/dist/spec/operations/calls.d.ts +8 -0
  472. package/dist/spec/operations/calls.d.ts.map +1 -0
  473. package/dist/spec/operations/calls.js +25 -0
  474. package/dist/spec/operations/calls.js.map +1 -0
  475. package/dist/spec/operations/chats.d.ts +115 -0
  476. package/dist/spec/operations/chats.d.ts.map +1 -0
  477. package/dist/spec/operations/chats.js +331 -0
  478. package/dist/spec/operations/chats.js.map +1 -0
  479. package/dist/spec/operations/contacts.d.ts +39 -0
  480. package/dist/spec/operations/contacts.d.ts.map +1 -0
  481. package/dist/spec/operations/contacts.js +135 -0
  482. package/dist/spec/operations/contacts.js.map +1 -0
  483. package/dist/spec/operations/folders.d.ts +31 -0
  484. package/dist/spec/operations/folders.d.ts.map +1 -0
  485. package/dist/spec/operations/folders.js +94 -0
  486. package/dist/spec/operations/folders.js.map +1 -0
  487. package/dist/spec/operations/login.d.ts +64 -0
  488. package/dist/spec/operations/login.d.ts.map +1 -0
  489. package/dist/spec/operations/login.js +105 -0
  490. package/dist/spec/operations/login.js.map +1 -0
  491. package/dist/spec/operations/messages.d.ts +142 -0
  492. package/dist/spec/operations/messages.d.ts.map +1 -0
  493. package/dist/spec/operations/messages.js +347 -0
  494. package/dist/spec/operations/messages.js.map +1 -0
  495. package/dist/spec/operations/session.d.ts +120 -0
  496. package/dist/spec/operations/session.d.ts.map +1 -0
  497. package/dist/spec/operations/session.js +202 -0
  498. package/dist/spec/operations/session.js.map +1 -0
  499. package/dist/spec/operations/uploads.d.ts +31 -0
  500. package/dist/spec/operations/uploads.d.ts.map +1 -0
  501. package/dist/spec/operations/uploads.js +59 -0
  502. package/dist/spec/operations/uploads.js.map +1 -0
  503. package/dist/spec/scalars.d.ts +17 -0
  504. package/dist/spec/scalars.d.ts.map +1 -0
  505. package/dist/spec/scalars.js +33 -0
  506. package/dist/spec/scalars.js.map +1 -0
  507. package/dist/transcribe/index.d.ts +70 -0
  508. package/dist/transcribe/index.d.ts.map +1 -0
  509. package/dist/transcribe/index.js +127 -0
  510. package/dist/transcribe/index.js.map +1 -0
  511. package/dist/transcribe/install.d.ts +2 -0
  512. package/dist/transcribe/install.d.ts.map +1 -0
  513. package/dist/transcribe/install.js +2 -0
  514. package/dist/transcribe/install.js.map +1 -0
  515. package/dist/transcribe/models.d.ts +5 -0
  516. package/dist/transcribe/models.d.ts.map +1 -0
  517. package/dist/transcribe/models.js +6 -0
  518. package/dist/transcribe/models.js.map +1 -0
  519. package/dist/transcribe/speech.d.ts +25 -0
  520. package/dist/transcribe/speech.d.ts.map +1 -0
  521. package/dist/transcribe/speech.js +107 -0
  522. package/dist/transcribe/speech.js.map +1 -0
  523. package/dist/update.d.ts +31 -0
  524. package/dist/update.d.ts.map +1 -0
  525. package/dist/update.js +69 -0
  526. package/dist/update.js.map +1 -0
  527. package/dist/upload.d.ts +15 -0
  528. package/dist/upload.d.ts.map +1 -0
  529. package/dist/upload.js +97 -0
  530. package/dist/upload.js.map +1 -0
  531. package/dist/version.d.ts +2 -0
  532. package/dist/version.d.ts.map +1 -0
  533. package/dist/version.js +2 -0
  534. package/dist/version.js.map +1 -0
  535. package/dist/voice.d.ts +9 -0
  536. package/dist/voice.d.ts.map +1 -0
  537. package/dist/voice.js +36 -0
  538. package/dist/voice.js.map +1 -0
  539. package/dist/wire-events.d.ts +23 -0
  540. package/dist/wire-events.d.ts.map +1 -0
  541. package/dist/wire-events.js +47 -0
  542. package/dist/wire-events.js.map +1 -0
  543. package/install/postinstall.mjs +3 -0
  544. package/install/windows.ps1 +160 -0
  545. package/package.json +120 -3
  546. package/skills/max-cli/SKILL.md +523 -0
package/dist/client.js ADDED
@@ -0,0 +1,2836 @@
1
+ import { CliError, realSleep } from "@wirecat/cli-core";
2
+ import { pickPerson as pickStoredPerson } from "@wirecat/cli-messaging";
3
+ import { providerErrorKey } from "@wirecat/cli-messaging/cli";
4
+ import { currentOperation, } from "@wirecat/cli-messaging/sends";
5
+ import { delayMs } from "./config.js";
6
+ import { fromPrivacy, namesFrom, POLL_CLOSED, pollSettings, SETTING_FLAGS, toButtons, toCallRecord, toChat, toContact, toFolder, toGroupCard, toGroupMember, toMessage, toPoll, toPrivacy, toProfile, toProfileFacts, toReactions, toSeen, toSession, toSticker, toStickerSet, } from "./domain/map.js";
7
+ import { wireClient } from "./generated/client.generated.js";
8
+ import { parseMarkdown } from "./markdown.js";
9
+ import { asFirstWord } from "./profile.js";
10
+ import { Connection, ProtocolError } from "./protocol/connection.js";
11
+ import { asId } from "./protocol/frame.js";
12
+ import { isId, pickChat } from "./resolve.js";
13
+ import { LOGIN_CHATS, startSession } from "./session/handshake.js";
14
+ import { tokenByQr } from "./session/login.js";
15
+ import { loginPausedUntil, withLoginRefused, withoutLoginPause, } from "./session/store.js";
16
+ import { WEB_USER_AGENT } from "./spec/identity.js";
17
+ import { buildRequest, checkResponse } from "./spec/index.js";
18
+ import { ASSET_TYPES } from "./spec/operations/assets.js";
19
+ import { isImage, isVideo, readUpload, uploadFile, uploadMedia, uploadPhoto } from "./upload.js";
20
+ import { voiceOf } from "./voice.js";
21
+ import { countsIn, idsOf } from "./wire-events.js";
22
+ /**
23
+ * **The only thing above this line that knows MAX exists.** Commands speak the domain model; the
24
+ * opcodes and the frames stop here, so a different transport underneath changes this file and
25
+ * nothing else (REQUIREMENTS §25).
26
+ *
27
+ * The methods are grouped by subject — `client.chats.list()`, `client.messages.send()` — which is
28
+ * the shape §8 sketched. **There is only this door.** The generated per-operation wrappers sit
29
+ * underneath and stay internal: they return raw MAX payloads, without the resend rule, the name
30
+ * filling or the id conversion that make this safe to point at a real account (`NEED-34`).
31
+ *
32
+ * One instance is one connection. `open` then `close`, always in a `finally`.
33
+ */
34
+ export class MaxClient {
35
+ #store;
36
+ #connection;
37
+ #fullLogin;
38
+ #resume;
39
+ #warn;
40
+ #record;
41
+ #offline;
42
+ #events;
43
+ #sleep;
44
+ #sends;
45
+ #invoke = ((operation, request) => this.#send(operation, request));
46
+ #wire = wireClient(this.#invoke);
47
+ #appliedOperations = new Set();
48
+ #login;
49
+ #chatsCut = false;
50
+ /** Every chat the account is in: only then may a chat absent from the list be marked as left. */
51
+ #chatsComplete = false;
52
+ #previousCid = 0;
53
+ #people;
54
+ #merged;
55
+ constructor({ store, timeoutMs, connection, warn, record, offline = false, events, sleep, sends, reads, fullLogin = false, resume, }) {
56
+ this.#store = store;
57
+ this.#fullLogin = fullLogin;
58
+ this.#resume = resume;
59
+ this.#connection = connection ?? new Connection(timeoutMs === undefined ? {} : { timeoutMs });
60
+ this.#warn = warn ?? ((message) => process.stderr.write(`${message}\n`));
61
+ this.#record = record;
62
+ this.#offline = offline;
63
+ this.#events = events ?? (() => { });
64
+ this.#sleep = sleep ?? realSleep;
65
+ this.#sends = sends === "caller" ? undefined : sends;
66
+ if (reads) {
67
+ const aliases = {
68
+ "account.me": "account.show",
69
+ "account.sessions": "account.sessions.list",
70
+ "account.endOtherSessions": "account.sessions.end",
71
+ "chats.markRead": "chats.mark-read",
72
+ "chats.adminIds": "chats.admins.list",
73
+ "chats.since": "messages.list",
74
+ "messages.around": "messages.context",
75
+ "messages.react": "reactions.add",
76
+ "messages.unreact": "reactions.remove",
77
+ "contacts.sync": "contacts.list",
78
+ };
79
+ const gated = (resource, methods) => new Proxy(methods, {
80
+ get: (target, key, receiver) => {
81
+ const value = Reflect.get(target, key, receiver);
82
+ const path = `${resource}.${String(key)}`;
83
+ if (typeof value === "object" && value !== null)
84
+ return gated(path, value);
85
+ return typeof value === "function"
86
+ ? (...args) => {
87
+ if (path !== "messages.moment" &&
88
+ !(path === "chats.resolve" && typeof args[0] === "string" && isId(args[0]))) {
89
+ const actual = path === "messages.pin" && args[1] === null ? "messages.unpin" : (aliases[path] ?? path);
90
+ reads(actual);
91
+ }
92
+ return Reflect.apply(value, target, args);
93
+ }
94
+ : value;
95
+ },
96
+ });
97
+ this.account = gated("account", this.account);
98
+ this.chats = gated("chats", this.chats);
99
+ this.contacts = gated("contacts", this.contacts);
100
+ this.messages = gated("messages", this.messages);
101
+ this.polls = gated("polls", this.polls);
102
+ this.folders = gated("chats.folders", this.folders);
103
+ }
104
+ }
105
+ account = {
106
+ me: async () => {
107
+ await this.#connectOnce();
108
+ return toProfile(record(this.#session().profile) ?? {});
109
+ },
110
+ /** What the login carried: MAX sends the account's settings with it, so reading them asks nothing more. */
111
+ privacy: async () => {
112
+ await this.#connectOnce();
113
+ return toPrivacy(record(record(this.#session().config)?.user) ?? {});
114
+ },
115
+ /** Only the owner's notifications change; `forever`, an ISO time, or null to hear the chat again. */
116
+ mute: (chatId, until) => this.#change("chat-mute", async () => {
117
+ const dontDisturbUntil = until === null ? 0 : until === "forever" ? -1 : Date.parse(until);
118
+ const chats = { [chatId]: { dontDisturbUntil } };
119
+ this.#keepSettings({ chats }, await this.#wire.account.settings({ settings: { chats } }));
120
+ }),
121
+ /** Only the settings named go out, under MAX's names; the answer is read back as `privacy` reads the login. */
122
+ updatePrivacy: (change) => this.#change("privacy", async () => {
123
+ const user = fromPrivacy(change);
124
+ this.#keepSettings({ user }, await this.#wire.account.settings({ settings: { user } }));
125
+ return toPrivacy(record(record(this.#session().config)?.user) ?? {});
126
+ }),
127
+ /** Newest first. The web tab asks the same with the sync value it last kept; 0 is the whole history. */
128
+ calls: async (limit) => {
129
+ await this.#connectOnce();
130
+ const answer = await this.#wire.calls.history({ callHistorySync: 0 });
131
+ const self = asId(record(record(this.#session().profile)?.contact)?.id);
132
+ const calls = asArray(answer.callHistoryItems)
133
+ .map((raw) => toCallRecord(record(raw) ?? {}, self))
134
+ .sort((a, b) => b.at.localeCompare(a.at));
135
+ return { items: calls.slice(0, limit), hasMore: calls.length > limit };
136
+ },
137
+ /**
138
+ * Changes the profile everyone sees. **The name goes out whole**: the web client always sends
139
+ * the current first name, so a change to the description alone keeps it from the login.
140
+ */
141
+ update: (change) => this.#change("profile", async () => {
142
+ const current = ownNames(record(this.#session().profile) ?? {});
143
+ const firstName = change.firstName ?? current.firstName;
144
+ if (!firstName)
145
+ throw new CliError("validation_error", "MAX needs a first name, and the profile has none — pass --first-name");
146
+ const lastName = change.lastName ?? current.lastName;
147
+ const photoToken = change.photo === undefined ? undefined : await this.#photoToken(change.photo, { profile: true });
148
+ const answer = await this.#wire.account.update({
149
+ firstName,
150
+ ...(lastName === undefined ? {} : { lastName }),
151
+ ...(change.description === undefined ? {} : { description: change.description }),
152
+ ...(photoToken === undefined ? {} : { photoToken, avatarType: "USER_AVATAR" }),
153
+ });
154
+ return toProfile(record(answer.profile) ?? {});
155
+ }),
156
+ sessions: async () => {
157
+ if (this.#offline)
158
+ throw new CliError("validation_error", "`--offline` reads what was recorded, and sessions are not recorded");
159
+ await this.#connectOnce();
160
+ return asArray((await this.#wire.account.sessions({})).sessions).map(toSession);
161
+ },
162
+ /**
163
+ * Logs every other device out — the owner's phone included — and keeps this one.
164
+ *
165
+ * PyMax reads a replacement token from the answer and the web client reads nothing (`RISK-25`),
166
+ * so both are handled: a token that came back is stored before anything is reported, and then
167
+ * the session is asked for once more, so a MAX that ended this one too says so here rather
168
+ * than on the next command.
169
+ */
170
+ endOtherSessions: () => this.#change("sessions-end", async (done) => {
171
+ const answer = await this.#wire.account.closeSessions({});
172
+ // The other devices are out from here on, whatever follows fails: the journal says so.
173
+ done();
174
+ const token = answer.token;
175
+ if (typeof token === "string" && token !== "" && this.#fromEnvironment()) {
176
+ this.#warnAbout("token_not_saved", "MAX gave this session a new token, and it was not saved: the one in use came from MAX_TOKEN — " +
177
+ "if MAX_TOKEN stops working, log in again and set it anew");
178
+ }
179
+ else if (typeof token === "string" && token !== "") {
180
+ try {
181
+ this.#store.writeToken(token);
182
+ }
183
+ catch (error) {
184
+ throw new CliError("configuration_error", `the other sessions are ended, but the new token for this one could not be saved: ${reasonOf(error)} — run \`max session start\``);
185
+ }
186
+ }
187
+ try {
188
+ return asArray((await this.#wire.account.sessions({})).sessions).map(toSession);
189
+ }
190
+ catch (error) {
191
+ throw new CliError("authentication_error", `the other sessions are ended, and MAX no longer answers this one (${asCliError(error).message}) — run \`max session start\``);
192
+ }
193
+ }),
194
+ };
195
+ /**
196
+ * Obtaining a token rather than using one. The token comes back to the caller, unstored and not yet
197
+ * tried: `adoptToken` logs in with it on a connection of its own before the keyring sees it, so
198
+ * a login that went wrong halfway cannot replace a working session.
199
+ */
200
+ login = {
201
+ byQr: (options) => this.#beforeLogin(() => tokenByQr(this.#wire, options)),
202
+ };
203
+ chats = {
204
+ /**
205
+ * Chats come with the login; a limit trims rather than fetching more.
206
+ *
207
+ * **A one-to-one chat has no title of its own** — its name is the other person's, which MAX
208
+ * does not put in the chat object. So the partner is looked up once, for every dialog at a
209
+ * time, and the name is filled in. Without this, `max chats` shows a column of blanks for
210
+ * exactly the chats a person recognises by name.
211
+ */
212
+ list: async (options = {}) => {
213
+ const { limit, offset = 0, kind, unread } = options;
214
+ const query = checkedQuery(options.query);
215
+ await this.#connectOnce();
216
+ const raw = asArray(this.#session().chats);
217
+ const people = await this.#peopleFor(raw);
218
+ // A one-to-one chat has no title of its own — its name is the other person's, and MAX does
219
+ // not put it in the chat object. Without this, the chats a person recognises by name are a
220
+ // column of blanks.
221
+ const chats = raw.map((chat) => {
222
+ const mapped = toChat(chat);
223
+ if (mapped.kind !== "dialog")
224
+ return mapped;
225
+ const partner = this.#partnerOf(chat);
226
+ const named = partner === undefined ? mapped : { ...mapped, providerMetadata: { partnerId: partner } };
227
+ if (mapped.title !== null)
228
+ return named;
229
+ return { ...named, title: partner === undefined ? null : (people.get(partner)?.name ?? null) };
230
+ });
231
+ let known = chats;
232
+ await this.#keep("chats", async (record) => {
233
+ known = await record.rememberChats(chats, this.#chatsComplete);
234
+ });
235
+ const partners = new Map(chats.map((chat) => [chat.id, chat.providerMetadata]));
236
+ const page = paged(matching(known.map((chat) => ({
237
+ ...chat,
238
+ ...(partners.get(chat.id) ? { providerMetadata: partners.get(chat.id) } : {}),
239
+ })), { query, kind, unread }), limit, offset);
240
+ // MAX holds chats it did not send; the last nonempty page says so, and an empty one ends a paging script.
241
+ return this.#chatsCut && page.items.length > 0 ? { ...page, hasMore: true } : page;
242
+ },
243
+ /**
244
+ * Turns what the person typed into a chat id.
245
+ *
246
+ * A number is taken as an id. Anything else is matched against chat titles, exactly first and
247
+ * then as a fragment — and **an ambiguous name is an error, not a guess**: sending to the wrong
248
+ * conversation is not undoable, so the caller is shown the candidates and asked to be specific.
249
+ */
250
+ resolve: async (reference) => {
251
+ if (isId(reference))
252
+ return reference.trim();
253
+ return pickChat(reference, (await this.chats.list()).items).id;
254
+ },
255
+ /**
256
+ * One chat and who is in it, from the list the login just refreshed.
257
+ *
258
+ * ⚠ **An id is checked here, where `resolve` lets any number through** — for a read that is
259
+ * the difference between an answer and a card of nulls for a chat that does not exist.
260
+ */
261
+ show: async (reference) => {
262
+ const { items } = await this.chats.list();
263
+ const chat = isId(reference) ? items.find((one) => one.id === reference.trim()) : pickChat(reference, items);
264
+ if (!chat)
265
+ throw new CliError("not_found", `no chat ${reference.trim()} among this account's chats`);
266
+ let members = null;
267
+ if (chat.kind !== "channel")
268
+ await this.#keep("members", async (record) => {
269
+ members = await record.members(chat.id);
270
+ });
271
+ // The login carries only the chats that changed lately; the settings of the rest are not known here.
272
+ const raw = this.#offline || chat.kind === "dialog"
273
+ ? undefined
274
+ : asArray(this.#session().chats).find((one) => asId(one.id) === chat.id);
275
+ const card = raw ? toGroupCard(raw) : undefined;
276
+ return {
277
+ ...chat,
278
+ members,
279
+ description: card?.description ?? null,
280
+ access: card?.access ?? null,
281
+ settings: card?.settings ?? null,
282
+ };
283
+ },
284
+ /** The other person in a one-to-one chat, by the chat's participants; `undefined` for anything else. */
285
+ partner: async (chatId) => {
286
+ await this.#connectOnce();
287
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
288
+ return raw ? this.#partnerOf(raw) : undefined;
289
+ },
290
+ /**
291
+ * The owner and admins of a group, from the chat as the login carried it (`owner`, `admins`,
292
+ * `adminParticipants` — measured 2026-09-24, `FIND-116`). `undefined` when the login did not
293
+ * carry this chat or its admins: the login carries only chats that changed lately, and the rest
294
+ * come from the local copy.
295
+ */
296
+ adminIds: async (chatId) => {
297
+ const roles = await this.#roles(chatId);
298
+ return roles && [...new Set([...(roles.owner ? [roles.owner] : []), ...roles.admins])];
299
+ },
300
+ /**
301
+ * Who joined, left, was added or removed since a point: the service messages in the chat's
302
+ * history, read forward from `since` without reactions. At most `EVENTS_READ` messages, the oldest.
303
+ */
304
+ events: async (reference, { since = Date.now() - EVENTS_DAYS * 86_400_000 } = {}) => {
305
+ if (this.#offline)
306
+ throw new CliError("validation_error", "`--offline` has no history to read events from");
307
+ const chatId = await this.chats.resolve(reference);
308
+ const { messages: read, more } = await this.chats.since(chatId, since);
309
+ const found = read.flatMap((message) => message.attachments
310
+ .filter((attachment) => attachment.kind === "control" && attachment.event !== undefined)
311
+ .map((attachment) => ({ message, attachment })));
312
+ const contacts = namesFrom(this.#session().contacts);
313
+ const ids = [
314
+ ...new Set([
315
+ ...found.flatMap(({ attachment }) => attachment.userIds ?? []),
316
+ ...found.flatMap(({ message }) => message.senderName === null && message.senderId ? [message.senderId] : []),
317
+ ]),
318
+ ];
319
+ const names = await this.#namesOf(ids.filter((id) => !contacts.has(id)));
320
+ const nameOf = (id) => contacts.get(id) ?? names.get(id) ?? null;
321
+ return {
322
+ chatId,
323
+ since: new Date(since).toISOString(),
324
+ more,
325
+ events: found.map(({ message, attachment }) => ({
326
+ messageId: message.id,
327
+ timestamp: message.timestamp,
328
+ event: attachment.event ?? "",
329
+ by: { id: message.senderId, name: message.senderName ?? (message.senderId && nameOf(message.senderId)) },
330
+ people: (attachment.userIds ?? []).map((id) => ({ id, name: nameOf(id) })),
331
+ ...(attachment.title ? { title: attachment.title } : {}),
332
+ })),
333
+ };
334
+ },
335
+ /**
336
+ * A chat's history after `since`, oldest first and without reactions, at most `EVENTS_READ`
337
+ * messages; `more` when there was more than that.
338
+ */
339
+ since: async (chatId, since) => {
340
+ const read = [];
341
+ let from = since;
342
+ while (true) {
343
+ const page = (await this.#history(chatId, { from, backward: 0, forward: HISTORY_PAGE + 1 }, { reactions: false })).filter((message) => Date.parse(message.timestamp) > from);
344
+ read.push(...page);
345
+ const last = page.at(-1);
346
+ if (page.length < HISTORY_PAGE || !last)
347
+ return { messages: read, more: false };
348
+ if (read.length >= EVENTS_READ)
349
+ return { messages: read, more: true };
350
+ from = Date.parse(last.timestamp);
351
+ }
352
+ },
353
+ /** What a link leads to, without joining it. */
354
+ inspect: async (link) => {
355
+ if (this.#offline)
356
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot read a link");
357
+ const wire = wireLink(link);
358
+ await this.#connectOnce();
359
+ const answer = await this.#wire.chats.linkInfo({ link: wire }).catch(deadLink(link));
360
+ return toGroupCard(record(answer.chat) ?? {});
361
+ },
362
+ /** Who asked to join a channel that approves joins. MAX says nothing of when, so `requestedAt` is null. */
363
+ joinRequests: async (chatId, { limit, search, link }) => {
364
+ if (this.#offline)
365
+ throw new CliError("validation_error", "`--offline` has no join requests; MAX has");
366
+ if (link !== undefined)
367
+ throw new CliError("validation_error", "MAX does not say which link a request came by");
368
+ await this.#connectOnce();
369
+ const answer = await this.#wire.chats.members({
370
+ chatId,
371
+ type: "JOIN_REQUEST",
372
+ count: limit,
373
+ ...(search ? { query: search } : {}),
374
+ });
375
+ const items = asArray(answer.members).map((raw) => {
376
+ const { id, name, username } = toGroupMember(raw);
377
+ return { person: { id, name, username }, requestedAt: null };
378
+ });
379
+ return { items, hasMore: typeof answer.marker === "number" && answer.marker > 0 };
380
+ },
381
+ /** Lets one person in, or turns them away; the web client's 77 with `type: JOIN_REQUEST`. */
382
+ answerJoinRequest: (chatId, personId, accept) => this.#changeChat(chatId, accept ? "requests.accept" : "requests.decline", async () => {
383
+ await this.#wire.chats.updateMembers({
384
+ chatId,
385
+ userIds: [personId],
386
+ type: "JOIN_REQUEST",
387
+ operation: accept ? "add" : "remove",
388
+ });
389
+ return { chatId, result: { already: false }, people: 1 };
390
+ }, accept ? [personId] : undefined),
391
+ /**
392
+ * `requested` where the chat takes join requests: MAX answers the chat with `joinRequestTime` and
393
+ * without the owner among its participants (measured 2026-10-08 on a channel), and the owner is not in it yet.
394
+ */
395
+ join: (link) => {
396
+ const wire = wireLink(link);
397
+ return this.#changeChat(null, "join", async () => {
398
+ const raw = record((await this.#wire.chats.join({ link: wire }).catch(deadLink(link))).chat) ?? {};
399
+ const chat = toGroupCard(raw);
400
+ return { chatId: chat.id, result: isJoinRequest(raw) ? { requested: true } : chat };
401
+ });
402
+ },
403
+ /**
404
+ * Marks a chat read up to `messageId`, or up to its newest message. The one call here that
405
+ * sends `CHAT_MARK`; nothing that reads does (REQUIREMENTS §19). The other person sees it, so it
406
+ * passes the send guard like a pin, and is not retried.
407
+ */
408
+ markRead: async (chatId, messageId) => {
409
+ if (this.#offline)
410
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot mark a chat read");
411
+ await this.#guard({ chatId, kind: "read" }, messageId);
412
+ try {
413
+ await this.#connectOnce();
414
+ const upTo = messageId ?? (await this.#history(chatId, { from: Date.now(), backward: 1, forward: 0 })).at(-1)?.id;
415
+ if (!upTo)
416
+ throw new CliError("not_found", `chat ${chatId} has no messages to mark read`);
417
+ // The web client's mark is the read message's own time, not the moment of reading: a later
418
+ // one would mark newer messages read too (captured 2026-09-25, `RES-10`).
419
+ const mark = timeOfMessageId(upTo);
420
+ if (mark === undefined)
421
+ throw new CliError("validation_error", `"${upTo}" is not a message id`);
422
+ const answer = await this.#wire.chats.mark({ type: "READ_MESSAGE", chatId, messageId: upTo, mark });
423
+ this.#sends?.record({ chatId, kind: "read", outcome: "sent", messageId: upTo });
424
+ return { chatId, messageId: upTo, unread: typeof answer.unread === "number" ? answer.unread : null };
425
+ }
426
+ catch (error) {
427
+ this.#sends?.record({ chatId, kind: "read", outcome: "failed", errorCode: asCliError(error).code });
428
+ throw error;
429
+ }
430
+ },
431
+ leave: async (reference) => {
432
+ const chatId = await this.chats.resolve(reference);
433
+ return this.#changeChat(chatId, "leave", async () => {
434
+ await this.#wire.chats.leave({ chatId });
435
+ return { chatId, result: { chatId, action: "leave", people: [], chat: null } };
436
+ });
437
+ },
438
+ /** For this account only: the others in it keep the chat and its messages. */
439
+ delete: async (reference) => {
440
+ const chatId = await this.chats.resolve(reference);
441
+ return this.#changeChat(chatId, "delete", async () => {
442
+ await this.#wire.chats.delete({ chatId, lastEventTime: this.#lastEventOf(chatId), forAll: false });
443
+ return { chatId, result: { chatId, action: "delete", people: [], chat: null } };
444
+ });
445
+ },
446
+ /**
447
+ * A bot's link to the chat with it — one never opened included — and the `?start=` it carries.
448
+ * `undefined` for anything that is not a link to a bot. Asks MAX only for a link.
449
+ */
450
+ botLink: async (reference) => {
451
+ if (!/^(https:\/\/)?max\.ru\/[\w.-]+\/?(\?.*)?$/.test(reference.trim()))
452
+ return undefined;
453
+ if (this.#offline)
454
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot read a link");
455
+ const url = new URL(reference.trim().startsWith("https://") ? reference.trim() : `https://${reference.trim()}`);
456
+ await this.#connectOnce();
457
+ const answer = await this.#wire.chats
458
+ .linkInfo({ link: `https://max.ru${url.pathname.replace(/\/$/, "")}` })
459
+ .catch(deadLink(reference));
460
+ const contact = record(record(answer.user)?.contact);
461
+ const botId = asId(contact?.id);
462
+ const options = Array.isArray(contact?.options) ? contact.options : [];
463
+ if (botId === undefined || !options.includes("BOT"))
464
+ throw new CliError("validation_error", `${reference.trim()} is not a bot's link`);
465
+ const viewerId = this.#store.readState().viewerId;
466
+ if (viewerId === undefined)
467
+ throw new CliError("not_found", "this login does not say whose account it is");
468
+ const payload = url.searchParams.get("start");
469
+ return { chatId: String(BigInt(viewerId) ^ BigInt(botId)), ...(payload ? { payload } : {}) };
470
+ },
471
+ /** Starts the bot in a one-to-one chat, as its Start button does: a service message to it. */
472
+ startBot: async (chatId, cid, payload) => {
473
+ if (this.#offline)
474
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot start a bot");
475
+ await this.#guard({ chatId, kind: "message", key: "chats.start" });
476
+ try {
477
+ await this.#connectOnce();
478
+ this.#botOf(chatId);
479
+ const control = {
480
+ _type: "CONTROL",
481
+ event: "botStarted",
482
+ ...(payload === undefined ? {} : { startPayload: payload }),
483
+ };
484
+ await this.#botWrite(() => this.#wire.messages.send({ chatId, message: { cid, attaches: [control] } }), "the bot may already have started", { sendId: cid });
485
+ this.#sends?.record({ chatId, kind: "message", sendId: String(cid), outcome: "sent" });
486
+ }
487
+ catch (error) {
488
+ const failure = asCliError(error);
489
+ this.#sends?.record({
490
+ chatId,
491
+ kind: "message",
492
+ sendId: String(cid),
493
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
494
+ errorCode: failure.code,
495
+ });
496
+ throw error;
497
+ }
498
+ },
499
+ /** The address of the bot's mini app. ⚠ It signs the owner in: returned to the caller, logged nowhere. */
500
+ app: async (chatId, startParam) => {
501
+ if (this.#offline)
502
+ throw new CliError("validation_error", "`--offline` reads what was recorded; ask MAX instead");
503
+ await this.#guard({ chatId, kind: "reaction", key: "chats.app" });
504
+ await this.#connectOnce();
505
+ const botId = this.#botOf(chatId);
506
+ const answer = await this.#wire.chats.app({ botId, chatId, ...(startParam ? { startParam } : {}) });
507
+ if (typeof answer.url !== "string" || answer.url === "")
508
+ throw new CliError("not_found", `the bot in chat ${chatId} has no mini app`);
509
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent" });
510
+ return { url: answer.url };
511
+ },
512
+ /** Every message, for this account only. */
513
+ clear: async (reference) => {
514
+ const chatId = await this.chats.resolve(reference);
515
+ return this.#changeChat(chatId, "clear", async () => {
516
+ await this.#wire.chats.clear({ chatId, lastEventTime: this.#lastEventOf(chatId), forAll: false });
517
+ return { chatId, result: { chatId, action: "clear", people: [], chat: null } };
518
+ });
519
+ },
520
+ /**
521
+ * Not retried, unlike a message: a second attempt with a new `cid` is a second group, and
522
+ * whether MAX deduplicates a creation by `cid` is not measured.
523
+ */
524
+ create: async (title, people = [], { channel = false } = {}) => {
525
+ const userIds = await this.#personIds(people);
526
+ return this.#changeChat(null, "create", async () => {
527
+ const answer = await this.#wire.messages.send({
528
+ message: {
529
+ cid: this.#nextCid(),
530
+ attaches: [{ _type: "CONTROL", event: "new", chatType: channel ? "CHANNEL" : "CHAT", title, userIds }],
531
+ },
532
+ notify: true,
533
+ });
534
+ const chat = toGroupCard(record(answer.chat) ?? {});
535
+ return { chatId: chat.id, result: chat, people: userIds.length };
536
+ }, userIds);
537
+ },
538
+ members: {
539
+ /**
540
+ * Everyone in a group or channel, from MAX, page by page. Paging follows PyMax: each answer's
541
+ * `marker` asks for the next page, and none means the last. Stops at `MEMBERS_READ`, or when a
542
+ * page repeats its marker or brings nobody new, so a misread marker cannot loop.
543
+ */
544
+ list: async (reference) => {
545
+ const { hasMore: _hasMore, truncated: _truncated, readCount: _readCount, ...found } = await this.#members(reference);
546
+ return found;
547
+ },
548
+ page: async (reference, window) => {
549
+ const { members: items, hasMore, chatId, rolesKnown, truncated, readCount, } = await this.#members(reference, window);
550
+ return { items, hasMore, chatId, rolesKnown, truncated, readCount };
551
+ },
552
+ /** No history unless asked (`NEED-272`): what was said before somebody joined is not theirs by default. */
553
+ add: (reference, people, { history = false } = {}) => this.#updateMembers(reference, people, "members.add", { operation: "add", showHistory: history }),
554
+ remove: (reference, people) => this.#updateMembers(reference, people, "members.remove", { operation: "remove", cleanMsgPeriod: 0 }),
555
+ },
556
+ admins: {
557
+ add: (reference, person, rights) => this.#updateMembers(reference, [person], "admins.add", {
558
+ operation: "add",
559
+ type: "ADMIN",
560
+ permissions: rights.reduce((sum, right) => sum | ADMIN_RIGHTS[right], 0),
561
+ }),
562
+ remove: (reference, person) => this.#updateMembers(reference, [person], "admins.remove", { operation: "remove", type: "ADMIN" }),
563
+ },
564
+ update: async (reference, { title, description, photo }) => {
565
+ if (title === undefined && description === undefined && photo === undefined) {
566
+ throw new CliError("validation_error", "nothing to change — give --title, --description or --photo");
567
+ }
568
+ const chatId = await this.chats.resolve(reference);
569
+ return this.#changeChat(chatId, "update", async () => {
570
+ const photoToken = photo === undefined ? undefined : await this.#photoToken(photo, { profile: false });
571
+ const answer = await this.#wire.chats.update({
572
+ chatId,
573
+ ...(title === undefined ? {} : { theme: title }),
574
+ ...(description === undefined ? {} : { description }),
575
+ ...(photoToken === undefined ? {} : { photoToken }),
576
+ });
577
+ return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
578
+ });
579
+ },
580
+ /** With no changes, reads the settings the login carried; nothing is sent. */
581
+ settings: async (reference, changes = {}) => {
582
+ if (this.#offline)
583
+ throw new CliError("validation_error", "`--offline` reads what was recorded; settings never are");
584
+ const chatId = await this.chats.resolve(reference);
585
+ const entries = Object.entries(changes).filter(([, value]) => value !== undefined);
586
+ if (entries.length === 0) {
587
+ await this.#connectOnce();
588
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
589
+ if (!raw) {
590
+ const shown = await this.chats.show(chatId);
591
+ return {
592
+ ...shown,
593
+ link: null,
594
+ settings: shown.settings ?? {
595
+ allCanPin: null,
596
+ onlyAdminsAdd: null,
597
+ onlyAdminsCall: null,
598
+ onlyOwnerEditsInfo: null,
599
+ membersSeeLink: null,
600
+ },
601
+ };
602
+ }
603
+ return toGroupCard(raw);
604
+ }
605
+ const options = Object.fromEntries(entries.map(([ours, value]) => [SETTING_FLAGS[ours], value]));
606
+ return this.#changeChat(chatId, "settings", async () => {
607
+ const answer = await this.#wire.chats.update({ chatId, options });
608
+ return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
609
+ });
610
+ },
611
+ /** The old link stops working for everyone who has it. */
612
+ resetLink: async (reference) => {
613
+ const chatId = await this.chats.resolve(reference);
614
+ return this.#changeChat(chatId, "link.reset", async () => {
615
+ const answer = await this.#wire.chats.update({ chatId, revokePrivateLink: true });
616
+ return { chatId, result: toGroupCard(record(answer.chat) ?? {}) };
617
+ });
618
+ },
619
+ };
620
+ contacts = {
621
+ /**
622
+ * Finds whoever MAX has under a phone number. A lookup, not an add: nothing changes on the
623
+ * account. ⚠ The number is never repeated in an error — it is the one field here the sixth
624
+ * constraint is about.
625
+ */
626
+ lookup: async (phone) => {
627
+ if (this.#offline)
628
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot look a number up");
629
+ const number = wirePhone(phone);
630
+ await this.#connectOnce();
631
+ const contact = record((await this.#wire.contacts.byPhone({ phone: number })).contact);
632
+ if (!contact)
633
+ throw new CliError("not_found", "MAX has nobody under that number");
634
+ return this.#remember(toContact(contact));
635
+ },
636
+ add: (reference) => this.#contactAction("contact-add", { action: "ADD" }, reference),
637
+ remove: (reference) => this.#contactAction("contact-remove", { action: "REMOVE" }, reference),
638
+ /** They can no longer write to you. Works for somebody who is not a contact too (measured). */
639
+ block: (reference) => this.#contactAction("contact-block", { action: "BLOCK" }, reference),
640
+ unblock: (reference) => this.#contactAction("contact-unblock", { action: "UNBLOCK" }, reference),
641
+ /** A name of your own for them, kept beside the one they chose; they do not see it. */
642
+ rename: (reference, firstName, lastName) => this.#contactAction("contact-rename", { action: "UPDATE", firstName, lastName: lastName ?? null }, reference),
643
+ /** Uploads phone numbers to MAX — other people's — under the names they were saved with. */
644
+ import: (entries) => this.#change("contact-import", async () => {
645
+ if (entries.length === 0)
646
+ throw new CliError("validation_error", "no numbers to import");
647
+ const contactList = {};
648
+ for (const entry of entries)
649
+ contactList[wirePhone(entry.phone)] = { firstName: entry.name };
650
+ const answer = await this.#wire.contacts.import({ contactList });
651
+ return {
652
+ sent: Object.keys(contactList).length,
653
+ recognised: Object.keys(record(answer.phones) ?? {}),
654
+ contacts: await Promise.all(asArray(answer.contacts)
655
+ .map(toContact)
656
+ .map((contact) => this.#remember(contact))),
657
+ };
658
+ }),
659
+ /**
660
+ * One person and the chats we share, **whoever they are** — a group member is as findable as
661
+ * a contact. `NEED-105` decides who `list` lists, not who can be looked up.
662
+ *
663
+ * Only from the store: the shared chats are its `chat_members`, which nothing else holds.
664
+ */
665
+ show: async (reference) => {
666
+ const record = this.#record;
667
+ if (!record)
668
+ throw new CliError("configuration_error", "no local store to look people up in");
669
+ await this.#connectOnce();
670
+ await this.#peopleFor(asArray(this.#session().chats));
671
+ const person = await this.#person(reference);
672
+ const chats = (await record.chatsWith(person.id)).map(({ id, title, kind, lastMessageAt }) => ({
673
+ id,
674
+ title,
675
+ kind,
676
+ lastMessageAt,
677
+ }));
678
+ return { ...person, chats };
679
+ },
680
+ /**
681
+ * What MAX says about one person — one `CONTACT_INFO` — and the chats shared with them, from
682
+ * the store as `show` reads them.
683
+ */
684
+ profile: async (reference) => {
685
+ const card = await this.contacts.show(reference);
686
+ const answer = await this.#wire.contacts.info({ contactIds: [card.id] });
687
+ const raw = asArray(answer.contacts).find((one) => asId(one.id) === card.id);
688
+ if (!raw)
689
+ throw new CliError("not_found", `MAX did not describe person ${card.id}`);
690
+ const presence = record((await this.#wire.contacts.presence({ contactIds: [card.id] })).presence);
691
+ const seen = toSeen(record(presence?.[card.id]));
692
+ return { ...toProfileFacts(raw, card.chats), ...(seen ? { seen } : {}) };
693
+ },
694
+ /**
695
+ * **Start again from zero**: forget the marker, so this login asks for the whole collection
696
+ * rather than for what changed, and name everybody it mentions.
697
+ *
698
+ * It is a repair tool, not the way contacts arrive. The delta rides on the login every command
699
+ * already performs, so there is nothing to schedule and no budget to spend — what this is for
700
+ * is a store that has drifted, or a full re-take after a schema rebuild threw the rows away.
701
+ */
702
+ sync: async () => {
703
+ if (this.#offline) {
704
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot sync");
705
+ }
706
+ const record = this.#record;
707
+ if (!record) {
708
+ throw new CliError("configuration_error", `there is no local store for profile "${this.#store.profile}" to sync into — the note above says why`);
709
+ }
710
+ // Before connecting, or the login would carry the marker this is meant to discard.
711
+ await this.#keep("marker", (record) => record.forgetMarker());
712
+ await this.#connectOnce();
713
+ // The login names a fraction of the people in its own chats, so a full take that stopped
714
+ // here would store ids without names for most of them.
715
+ await this.#peopleFor(asArray(this.#session().chats));
716
+ const known = (await record.people()).all().length;
717
+ return { ...(this.#merged ?? { known: 0, added: 0, changed: 0 }), known, full: true };
718
+ },
719
+ };
720
+ messages = {
721
+ /** A bot's keyboard under one message, row by row; `[]` for a message without one. */
722
+ buttons: async (chatId, messageId) => {
723
+ if (this.#offline)
724
+ throw new CliError("validation_error", "`--offline` reads what was recorded; ask MAX instead");
725
+ await this.#connectOnce();
726
+ const keyboard = keyboardOf(await this.#rawMessage(chatId, messageId));
727
+ return keyboard ? toButtons(keyboard) : [];
728
+ },
729
+ /** Presses one callback button; the bot learns who pressed it. Only the attach's handle and the button's payload go. */
730
+ press: async (chatId, messageId, row, column) => {
731
+ if (this.#offline)
732
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot press");
733
+ await this.#guard({ chatId, kind: "reaction", key: "messages.press" }, messageId);
734
+ try {
735
+ await this.#connectOnce();
736
+ const keyboard = keyboardOf(await this.#rawMessage(chatId, messageId));
737
+ const rows = record(keyboard?.keyboard)?.buttons;
738
+ const button = record(Array.isArray(rows) && Array.isArray(rows[row]) ? rows[row][column] : undefined);
739
+ if (!keyboard || typeof keyboard.callbackId !== "string" || !button)
740
+ throw new CliError("not_found", `message ${messageId} has no such button`);
741
+ if (button.type !== "CALLBACK")
742
+ throw new CliError("validation_error", "only a callback button can be pressed");
743
+ const callbackId = keyboard.callbackId;
744
+ await this.#botWrite(() => this.#wire.messages.press({
745
+ callbackId,
746
+ type: "CALLBACK",
747
+ ...(typeof button.payload === "string" && button.payload !== "" ? { payload: button.payload } : {}),
748
+ timestamp: Date.now(),
749
+ }), "the bot may already have received this button press", { messageId });
750
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
751
+ }
752
+ catch (error) {
753
+ const failure = asCliError(error);
754
+ this.#sends?.record({
755
+ chatId,
756
+ kind: "reaction",
757
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
758
+ messageId,
759
+ errorCode: failure.code,
760
+ });
761
+ throw error;
762
+ }
763
+ },
764
+ /**
765
+ * The chat's queue of scheduled messages, soonest first. Read-only: cancelling is `MSG_DELETE`,
766
+ * which nothing here sends. Never cached — the queue empties by itself as messages go out.
767
+ */
768
+ scheduled: async (chatId) => {
769
+ if (this.#offline)
770
+ throw new CliError("validation_error", "`--offline` has no record of the queue; it lives on MAX");
771
+ await this.#connectOnce();
772
+ const session = this.#session();
773
+ const answer = await this.#wire.chats.history({
774
+ chatId,
775
+ from: 1,
776
+ forward: 100,
777
+ backward: 0,
778
+ forwardTime: 0,
779
+ backwardTime: 0,
780
+ itemType: "DELAYED",
781
+ getChat: false,
782
+ getMessages: true,
783
+ });
784
+ const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
785
+ return asArray(answer.messages)
786
+ .map((raw) => toMessage(raw, chatId, lookup))
787
+ .sort((a, b) => (a.scheduledFor ?? "").localeCompare(b.scheduledFor ?? ""));
788
+ },
789
+ /**
790
+ * **Paged backwards through time, not by page number.** MAX's history is already anchored —
791
+ * it takes a moment and answers with what came before it — so `--before` is exact where a page
792
+ * number over a live conversation would repeat and skip rows.
793
+ *
794
+ * `after` reads the other way and **leaves the anchor out**, so the next page's hint does not
795
+ * repeat a row. MAX puts it in — measured 2026-09-23, `forward: n` with `backward: 0` starts
796
+ * with the message it was given — so the window starts a millisecond later, and one more than the page is
797
+ * asked for to tell whether another follows. Asking from the moment itself spent that one on the anchor.
798
+ * `before` keeps the anchor, as it always has.
799
+ *
800
+ * `hasMore` here is a claim about the copy we hold, never about the chat. MAX's answer carries
801
+ * `messages` and nothing that says whether older ones exist, and a page can come back short in the
802
+ * middle of a chat, as Telegram's does. So a short page back is checked with one more request for a
803
+ * single older message; a full page is taken as more, and an empty one as the start.
804
+ */
805
+ list: async (chatId, options = {}) => {
806
+ const limit = options.limit ?? 20;
807
+ const { before, after, reactions = true } = options;
808
+ if (after !== undefined) {
809
+ const found = await this.#history(chatId, { from: after + 1, backward: 0, forward: limit + 1 }, { reactions });
810
+ const later = found.filter((message) => Date.parse(message.timestamp) > after);
811
+ return { items: later.slice(0, limit), hasMore: later.length > limit };
812
+ }
813
+ const messages = await this.#history(chatId, { from: before ?? Date.now(), backward: limit, forward: 0 }, { reactions });
814
+ const hasMore = messages.length >= limit || (messages.length > 0 && (await this.#olderThan(chatId, messages)));
815
+ return { items: messages, hasMore };
816
+ },
817
+ /**
818
+ * **A chat's gallery**, as web.max.ru asks for it: messages that carry these kinds of attachment,
819
+ * read back from a message — the newest one, or `before`. Oldest first, like history.
820
+ */
821
+ media: async (chatId, kinds, limit, before) => {
822
+ await this.#connectOnce();
823
+ const anchor = before ?? (await this.#newestId(chatId));
824
+ if (anchor === undefined)
825
+ return { items: [], hasMore: false };
826
+ const answer = await this.#wire.messages.media({
827
+ chatId,
828
+ messageId: anchor,
829
+ attachTypes: [...new Set(kinds.map((kind) => MEDIA_TYPES[kind]))],
830
+ forward: 0,
831
+ backward: limit + 1,
832
+ });
833
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
834
+ const raw = asArray(answer.messages)
835
+ .map((one) => record(one) ?? {})
836
+ .filter((one) => asId(one.id) !== before);
837
+ const older = raw.length > limit;
838
+ const items = await this.#nameSenders(raw.slice(older ? raw.length - limit : 0).map((one) => toMessage(one, chatId, lookup)));
839
+ return { items, hasMore: older };
840
+ },
841
+ /**
842
+ * **MAX's own text search, in one chat**, newest first as the server answers. It matches a word's
843
+ * beginning, not another form of a Russian word (measured 2026-10-07): the caller treats the answer
844
+ * as candidates. No reactions, no read marks.
845
+ */
846
+ search: async (chatId, query, limit) => {
847
+ await this.#connectOnce();
848
+ const answer = await this.#wire.messages.search({ chatId, query, count: limit });
849
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
850
+ const raw = asArray(answer.result).flatMap((one) => {
851
+ const message = record(record(one)?.message);
852
+ return message === undefined ? [] : [message];
853
+ });
854
+ const items = await this.#nameSenders(raw.map((one) => toMessage(one, chatId, lookup)));
855
+ return { items, hasMore: items.length >= limit };
856
+ },
857
+ /**
858
+ * **One message and a window either side of it**, oldest first, the one asked for marked
859
+ * `anchor: true`.
860
+ *
861
+ * Measured 2026-09-22: from a message's own time, `backward: n` answers n messages ending with
862
+ * it and `forward: n` the n after it. Its time comes from its id, so no stored copy is needed.
863
+ * Correction 2026-09-23: that holds with `backward` above zero; with `backward: 0`, `forward`
864
+ * starts with the message itself (`messages.list` with `after` depends on the difference).
865
+ *
866
+ * ⚠ **A message that is gone is refused, not replaced by its neighbour** — MAX answers with
867
+ * whatever is nearest, and showing that as the message asked for would be a quiet lie.
868
+ */
869
+ around: async (chatId, messageId, { before = 0, after = 0, reactions = true } = {}) => {
870
+ const time = timeOfMessageId(messageId);
871
+ if (time === undefined)
872
+ throw new CliError("validation_error", `"${messageId}" is not a message id`);
873
+ const found = await this.#history(chatId, { from: time, backward: before + 1, forward: after }, { reactions });
874
+ if (!found.some((message) => message.id === messageId)) {
875
+ throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
876
+ }
877
+ return found.map((message) => (message.id === messageId ? { ...message, anchor: true } : message));
878
+ },
879
+ /**
880
+ * **Where each attachment of one message can be downloaded from.** A photo and an audio carry
881
+ * their link; a file and a video carry only an id, and MAX answers the link for it (measured
882
+ * 2026-09-23). A video is taken as its largest MP4 — the streaming renditions are playlists,
883
+ * not a file. An attachment with no link to give is left out and named in `skipped`.
884
+ */
885
+ links: async (chatId, messageId) => {
886
+ if (this.#offline)
887
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot download");
888
+ const [message] = await this.messages.around(chatId, messageId, { reactions: false });
889
+ if (!message)
890
+ throw new CliError("not_found", `no message ${messageId} in chat ${chatId}`);
891
+ const links = [];
892
+ const skipped = [];
893
+ for (const attachment of message.attachments) {
894
+ const { kind, name } = attachment;
895
+ if (attachment.fileId) {
896
+ const answer = await this.#wire.attachments.file({ chatId, messageId, fileId: attachment.fileId });
897
+ const url = typeof answer.url === "string" ? answer.url : undefined;
898
+ if (url)
899
+ links.push({ kind, url, ...(name ? { name } : {}), ...(answer.unsafe === true ? { unsafe: true } : {}) });
900
+ else
901
+ skipped.push(kind);
902
+ }
903
+ else if (attachment.videoId) {
904
+ const answer = await this.#wire.attachments.video({ chatId, messageId, videoId: attachment.videoId });
905
+ const url = largestMp4(answer);
906
+ if (url)
907
+ links.push({ kind, url });
908
+ else
909
+ skipped.push(kind);
910
+ }
911
+ else if (attachment.url && (kind === "photo" || kind === "audio")) {
912
+ links.push({ kind, url: attachment.url });
913
+ }
914
+ else {
915
+ skipped.push(kind);
916
+ }
917
+ }
918
+ return { links, skipped };
919
+ },
920
+ /**
921
+ * Turns what `--before` or `--after` was given into a moment.
922
+ *
923
+ * **ISO 8601 is a time; a bare integer is a message id**, and a message id carries its own time
924
+ * (`timeOfMessageId`). Deciding between the two by length would be a trap that fires the first
925
+ * time either changes size, so the rule is what the string looks like.
926
+ */
927
+ moment: (reference, flag = "--before") => {
928
+ const wanted = reference.trim();
929
+ const ago = delayMs(wanted);
930
+ const time = /^\d+$/.test(wanted)
931
+ ? timeOfMessageId(wanted)
932
+ : ago === undefined
933
+ ? Date.parse(wanted)
934
+ : Date.now() - ago;
935
+ if (time === undefined || Number.isNaN(time)) {
936
+ throw new CliError("validation_error", `${flag} takes a message id, an ISO 8601 time or 30m, 2h, 1d ago — not "${wanted}"`);
937
+ }
938
+ return time;
939
+ },
940
+ /**
941
+ * Sends one message, retrying **only with the same `cid`**.
942
+ *
943
+ * §17 allows a send to be retried when the protocol has a verified deduplication handle. It has
944
+ * one, measured against MAX on 2026-09-19: sending the same `cid` twice returned the same
945
+ * message id and left **one** copy in the chat — and it held across two separate connections
946
+ * and logins, which is the case a retry actually faces.
947
+ *
948
+ * So one retry, same `cid`, and nothing beyond that. What is still unmeasured is how long the
949
+ * server remembers a `cid`; the two probes were seconds apart. If the retry also fails the
950
+ * answer is `outcome_unknown` — never failed, never sent — and it names the `cid`, because
951
+ * `max messages send … --send-id <n>` can then repeat the attempt without risking a second message.
952
+ *
953
+ * **`at` queues it on MAX** (epoch ms), which sends it then even with this machine off. That is
954
+ * never retried: deduplication by `cid` was measured for ordinary messages only, and a second
955
+ * copy would surface later, where nobody is watching. The guard counts it now, when it is queued.
956
+ */
957
+ send: async (chatId, text, options = {}) => {
958
+ if (this.#offline)
959
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot send");
960
+ if (options.voice !== undefined && (text !== "" || (options.files ?? []).length > 0)) {
961
+ // MAX kept a caption beside a voice message (measured 2026-09-27), but web.max.ru never sends one; §34.
962
+ throw new CliError("validation_error", "a voice message goes alone — no text and no --file beside it");
963
+ }
964
+ if (options.sticker !== undefined && (text !== "" || (options.files ?? []).length > 0 || options.voice))
965
+ throw new CliError("validation_error", "a sticker goes alone — no text, no file, no photo");
966
+ if (options.at !== undefined && options.notify === false) {
967
+ // The web client always sends a scheduled message with `notify: true`; §34.
968
+ throw new CliError("validation_error", "a scheduled message cannot be silent — MAX's own client never sends one");
969
+ }
970
+ // Read before the guard holds a place under the limit: a file that is refused sends nothing.
971
+ const files = await Promise.all((options.files ?? []).map(async (path) => ({
972
+ path,
973
+ bytes: await readUpload(path, { anyFile: options.anyFile === true }),
974
+ kind: isImage(path) ? "photo" : isVideo(path) && options.asFile !== true ? "video" : "file",
975
+ })));
976
+ for (const { name, bytes, kind } of options.uploads ?? []) {
977
+ const held = Buffer.from(bytes);
978
+ files.push({ path: name, bytes: held, kind, ...(kind === "voice" ? { voice: await voiceOf(name, held) } : {}) });
979
+ }
980
+ if (options.voice !== undefined) {
981
+ const bytes = await readUpload(options.voice, { anyFile: options.anyFile === true });
982
+ files.push({ path: options.voice, bytes, kind: "voice", voice: await voiceOf(options.voice, bytes) });
983
+ }
984
+ // Measured 2026-09-24: photos share a message, but a file with anything beside it is refused `proto.payload`.
985
+ // A video is kept alone the same way; a mixed message was never measured.
986
+ if (files.some((file) => file.kind !== "photo") && files.length > 1) {
987
+ throw new CliError("validation_error", "a file or a video goes in a message of its own — photos can share one; send them apart");
988
+ }
989
+ const cid = options.cid ?? this.#nextCid();
990
+ const sendId = String(cid);
991
+ // Before connecting: a refused send never opens a socket when the chat was given as an id.
992
+ try {
993
+ await this.#checked({
994
+ chatId,
995
+ kind: "message",
996
+ sendId,
997
+ ...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
998
+ });
999
+ }
1000
+ catch (error) {
1001
+ this.#sends?.record({ chatId, outcome: "refused", sendId, errorCode: asCliError(error).code });
1002
+ throw error;
1003
+ }
1004
+ const attachments = files.map(({ bytes, kind }) => ({ kind, bytes: bytes.length }));
1005
+ const summary = {
1006
+ ...(attachments.length > 0 ? { attachments } : {}),
1007
+ ...(options.at === undefined ? {} : { scheduledFor: new Date(options.at).toISOString() }),
1008
+ };
1009
+ try {
1010
+ const sticker = options.sticker === undefined ? [] : [{ _type: "STICKER", stickerId: Number(options.sticker) }];
1011
+ const sent = await this.#deliver(chatId, text, cid, { ...options, files, attaches: sticker });
1012
+ this.#sends?.record({ chatId, outcome: "sent", messageId: sent.id, sendId, length: text.length, ...summary });
1013
+ return sent;
1014
+ }
1015
+ catch (error) {
1016
+ const failure = asCliError(error);
1017
+ this.#sends?.record({
1018
+ chatId,
1019
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
1020
+ sendId,
1021
+ length: text.length,
1022
+ ...summary,
1023
+ errorCode: failure.code,
1024
+ });
1025
+ throw error;
1026
+ }
1027
+ },
1028
+ /** Puts one emoji reaction on a message; it replaces the one you had. */
1029
+ react: (chatId, messageId, emoji) => this.#reaction(chatId, messageId, () => this.#wire.messages.react({ chatId, messageId, reaction: { reactionType: "EMOJI", id: emoji } })),
1030
+ /** Takes your reaction off. Measured 2026-09-24: a second call is answered the same, not refused. */
1031
+ unreact: (chatId, messageId) => this.#reaction(chatId, messageId, () => this.#wire.messages.unreact({ chatId, messageId }), "reactions.remove"),
1032
+ /**
1033
+ * Changes the text of one of the owner's own messages. The person may have read it already.
1034
+ *
1035
+ * **Its attachments are sent back as history gives them**: measured 2026-09-24, an edit with
1036
+ * none removes a photo from the message. Refused before asking MAX when the message is not the
1037
+ * owner's, is a forward, or carries anything but photos — the web client offers none of those. MAX's own limit is
1038
+ * `edit-timeout` from LOGIN, 604800 s when measured; past it MAX refuses and that is the answer.
1039
+ * Not retried, like a reaction.
1040
+ */
1041
+ edit: async (chatId, messageId, text, { markdown = false, markup } = {}) => {
1042
+ if (this.#offline)
1043
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot edit");
1044
+ await this.#guard({ chatId, kind: "edit" }, messageId);
1045
+ try {
1046
+ await this.#connectOnce();
1047
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
1048
+ const raw = await this.#rawMessage(chatId, messageId);
1049
+ const current = toMessage(raw, chatId, lookup);
1050
+ if (current.outgoing !== true) {
1051
+ throw new CliError("validation_error", `message ${messageId} is not yours — only your own can be edited`);
1052
+ }
1053
+ if (record(raw.link)?.type === "FORWARD") {
1054
+ throw new CliError("validation_error", `message ${messageId} is a forward — a forward cannot be edited`);
1055
+ }
1056
+ // Only a photo was measured to survive the round trip; the web client refuses files, stickers and the rest too.
1057
+ const other = current.attachments.find((attachment) => attachment.kind !== "photo");
1058
+ if (other) {
1059
+ throw new CliError("validation_error", `message ${messageId} carries a ${other.kind} — only text and photos can be edited`);
1060
+ }
1061
+ const parsed = markup ? { text, markup } : markdown ? parseMarkdown(text) : { text, markup: [] };
1062
+ const answer = await this.#wire.messages.edit({
1063
+ chatId,
1064
+ messageId,
1065
+ text: parsed.text,
1066
+ elements: parsed.markup,
1067
+ attachments: asArray(raw.attaches),
1068
+ });
1069
+ this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId, length: text.length });
1070
+ return toMessage(record(answer.message) ?? raw, chatId, lookup);
1071
+ }
1072
+ catch (error) {
1073
+ const failure = asCliError(error);
1074
+ this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: failure.code });
1075
+ throw error;
1076
+ }
1077
+ },
1078
+ /**
1079
+ * Forwards one message into another chat: a new message there, so it goes through `#deliver`
1080
+ * — the same `cid`, the same one retry — and counts against `sendsPerHour`.
1081
+ */
1082
+ forward: async (fromChatId, messageId, toChatId, options = {}) => {
1083
+ if (this.#offline)
1084
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot forward");
1085
+ const cid = options.cid ?? this.#nextCid();
1086
+ const sendId = String(cid);
1087
+ await this.#guard({ chatId: toChatId, kind: "forward", sendId });
1088
+ try {
1089
+ const sent = await this.#deliver(toChatId, "", cid, {
1090
+ ...options,
1091
+ forward: { chatId: fromChatId, messageId },
1092
+ repeat: `max messages forward ${fromChatId} ${messageId} --to ${toChatId}`,
1093
+ });
1094
+ this.#sends?.record({ chatId: toChatId, kind: "forward", outcome: "sent", messageId: sent.id, sendId });
1095
+ return sent;
1096
+ }
1097
+ catch (error) {
1098
+ const failure = asCliError(error);
1099
+ this.#sends?.record({
1100
+ chatId: toChatId,
1101
+ kind: "forward",
1102
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
1103
+ sendId,
1104
+ errorCode: failure.code,
1105
+ });
1106
+ throw error;
1107
+ }
1108
+ },
1109
+ /**
1110
+ * Deletes messages — for this account only, or with `forEveryone` for everyone in the chat. The
1111
+ * owner's word is the command's `--allow-dangerous`; here it is the guard, and each message
1112
+ * counts toward the hourly limit (`MAX-47`). Whose message it is, MAX decides: an admin may
1113
+ * delete somebody else's in a group. Not retried, like an edit.
1114
+ */
1115
+ delete: async (chatId, messageIds, { forEveryone = false } = {}) => {
1116
+ if (this.#offline)
1117
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot delete");
1118
+ if (messageIds.length === 0)
1119
+ throw new CliError("validation_error", "name at least one message to delete");
1120
+ if (messageIds.length > DELETE_AT_ONCE) {
1121
+ throw new CliError("validation_error", `${messageIds.length} messages at once — at most ${DELETE_AT_ONCE} per call, so MAX sees deletions spread out`);
1122
+ }
1123
+ const times = messageIds.map((id) => {
1124
+ const time = timeOfMessageId(id);
1125
+ if (time === undefined)
1126
+ throw new CliError("validation_error", `"${id}" is not a message id`);
1127
+ return time;
1128
+ });
1129
+ const count = messageIds.length;
1130
+ await this.#guard({ chatId, kind: "delete", count, forEveryone });
1131
+ try {
1132
+ await this.#connectOnce();
1133
+ await this.#wire.messages.delete({ chatId, messageIds, forMe: !forEveryone });
1134
+ const retained = [];
1135
+ try {
1136
+ for (const [index, id] of messageIds.entries()) {
1137
+ const answer = await this.#wire.chats.history({
1138
+ chatId,
1139
+ from: times[index],
1140
+ backward: 1,
1141
+ forward: 0,
1142
+ getMessages: true,
1143
+ });
1144
+ if (!Array.isArray(answer.messages))
1145
+ throw new Error("missing history");
1146
+ if (answer.messages.some((raw) => asId(record(raw)?.id) === id && record(raw)?.status !== "REMOVED"))
1147
+ retained.push(id);
1148
+ }
1149
+ }
1150
+ catch {
1151
+ throw new CliError("outcome_unknown", "MAX accepted the deletion but its result could not be verified — read the messages before any retry", { reason: "deletion_unconfirmed" });
1152
+ }
1153
+ if (retained.length)
1154
+ throw new CliError("outcome_unknown", "MAX still returns messages after the deletion — read them before any retry", { reason: "deletion_unconfirmed", retained });
1155
+ this.#sends?.record({ chatId, kind: "delete", outcome: "sent", count, forEveryone });
1156
+ return { chatId, deleted: messageIds, forEveryone };
1157
+ }
1158
+ catch (error) {
1159
+ this.#sends?.record({
1160
+ chatId,
1161
+ kind: "delete",
1162
+ outcome: "failed",
1163
+ count,
1164
+ forEveryone,
1165
+ errorCode: asCliError(error).code,
1166
+ });
1167
+ throw error;
1168
+ }
1169
+ },
1170
+ /**
1171
+ * Pins one message in a chat, or with `null` unpins whatever is pinned — `pinMessageId: 0` is
1172
+ * how the web client unpins. No notification unless asked (`NEED-196`). Not retried.
1173
+ *
1174
+ * **Never in a personal chat**: the web client's dialog class answers `viewerCanPin` with `false`, and
1175
+ * MAX refused both in Saved messages and in a dialog with a person (measured 2026-09-24, `FIND-107`).
1176
+ * A chat the login did not list is left to MAX. In a group, pin and unpin were measured the same day.
1177
+ */
1178
+ pin: async (chatId, messageId, { notify = false } = {}) => {
1179
+ if (this.#offline)
1180
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot pin");
1181
+ await this.#guard({ chatId, kind: "pin", notify, key: messageId === null ? "messages.unpin" : "messages.pin" }, messageId ?? undefined);
1182
+ try {
1183
+ await this.#connectOnce();
1184
+ const known = asArray(this.#session().chats).find((raw) => asId(raw.id) === chatId);
1185
+ if (known && toChat(known).kind === "dialog") {
1186
+ throw new CliError("validation_error", `chat ${chatId} is a personal chat — MAX pins only in groups and channels`);
1187
+ }
1188
+ await this.#wire.chats.update({ chatId, pinMessageId: messageId ?? "0", notifyPin: notify });
1189
+ this.#sends?.record({ chatId, kind: "pin", notify, outcome: "sent", ...(messageId ? { messageId } : {}) });
1190
+ return { chatId, pinned: messageId };
1191
+ }
1192
+ catch (error) {
1193
+ this.#sends?.record({ chatId, kind: "pin", notify, outcome: "failed", errorCode: asCliError(error).code });
1194
+ throw error;
1195
+ }
1196
+ },
1197
+ };
1198
+ /**
1199
+ * **Polls: vote, take a vote back, close one's own, create one.** Every one of them is a write
1200
+ * somebody else can see, so it passes the send guard and the journal under the kind of what it is:
1201
+ * a vote is a `reaction`, closing is an `edit` of the owner's message, a new poll is a `message`.
1202
+ * A vote is never retried — nothing measured makes a blind repeat safe.
1203
+ *
1204
+ * The shapes were measured in Saved messages on 2026-09-27 (`pnpm probe:polls`, `FIND-247`). The
1205
+ * checks before sending are the web client's, so what it would refuse never reaches MAX.
1206
+ */
1207
+ polls = {
1208
+ /** One poll as it stands, read from its message; changes nothing. */
1209
+ show: async (chatId, messageId) => {
1210
+ await this.#connectOnce();
1211
+ return { chatId, messageId, poll: (await this.#poll(chatId, messageId)).poll };
1212
+ },
1213
+ vote: async (chatId, messageId, answerIds) => {
1214
+ if (this.#offline)
1215
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot vote");
1216
+ await this.#guard({ chatId, kind: "reaction", key: "polls.vote" }, messageId);
1217
+ try {
1218
+ await this.#connectOnce();
1219
+ const { attach, poll } = await this.#poll(chatId, messageId);
1220
+ const voted = poll.answers.some((answer) => answer.mine);
1221
+ if (answerIds.length === 0 && !voted) {
1222
+ throw new CliError("validation_error", `you have not voted in the poll of message ${messageId}`);
1223
+ }
1224
+ if (answerIds.length === 0 && !poll.revote) {
1225
+ throw new CliError("validation_error", `the poll of message ${messageId} does not let a vote be taken back`);
1226
+ }
1227
+ if (answerIds.length > 0 && voted && !poll.revote) {
1228
+ throw new CliError("validation_error", `you have voted in the poll of message ${messageId}, and it does not let a vote change`);
1229
+ }
1230
+ if (answerIds.length > 1 && !poll.multiple) {
1231
+ throw new CliError("validation_error", `the poll of message ${messageId} takes one answer, not ${answerIds.length}`);
1232
+ }
1233
+ const unknown = answerIds.find((id) => !poll.answers.some((answer) => answer.id === id));
1234
+ if (unknown !== undefined) {
1235
+ throw new CliError("validation_error", `the poll of message ${messageId} has no answer ${unknown} — its answers are ${poll.answers.map((answer) => answer.id).join(", ")}`);
1236
+ }
1237
+ const answer = await this.#wire.messages.pollVote({
1238
+ chatId,
1239
+ messageId,
1240
+ pollId: poll.id,
1241
+ answersIds: answerIds.map(Number),
1242
+ });
1243
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
1244
+ const state = record(answer.state);
1245
+ return { chatId, messageId, poll: (state && toPoll({ ...attach, state })) ?? poll };
1246
+ }
1247
+ catch (error) {
1248
+ const failure = asCliError(error);
1249
+ this.#sends?.record({
1250
+ chatId,
1251
+ kind: "reaction",
1252
+ outcome: "failed",
1253
+ messageId,
1254
+ errorCode: failure.code,
1255
+ });
1256
+ if (answerIds.length > 0 && failure.details.providerError === "poll.already.voted") {
1257
+ throw new CliError(failure.code, `${failure.message}; if this poll allows changing votes, run \`polls vote <chat> <message> --retract\` in the same profile before voting again`, failure.details);
1258
+ }
1259
+ throw error;
1260
+ }
1261
+ },
1262
+ /** Closes the owner's own poll: the web client edits the message with the `closed` bit raised. */
1263
+ close: async (chatId, messageId) => {
1264
+ if (this.#offline)
1265
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot close a poll");
1266
+ await this.#guard({ chatId, kind: "edit", key: "polls.close" }, messageId);
1267
+ try {
1268
+ await this.#connectOnce();
1269
+ const { attach, poll, outgoing } = await this.#poll(chatId, messageId);
1270
+ if (!outgoing)
1271
+ throw new CliError("validation_error", `the poll of message ${messageId} is not yours to close`);
1272
+ // What the web client sends back: the answers' text only, no ids and no votes.
1273
+ const closed = {
1274
+ _type: "POLL",
1275
+ pollId: attach.pollId,
1276
+ title: attach.title,
1277
+ answers: asArray(attach.answers).map((each) => ({ text: each.text })),
1278
+ settings: (typeof attach.settings === "number" ? attach.settings : 0) | POLL_CLOSED,
1279
+ };
1280
+ const answer = await this.#wire.messages.edit({ chatId, messageId, attachments: [closed] });
1281
+ this.#sends?.record({ chatId, kind: "edit", outcome: "sent", messageId });
1282
+ const after = asArray(record(answer.message)?.attaches).find((each) => each._type === "POLL");
1283
+ return { chatId, messageId, poll: (after && toPoll(after)) ?? { ...poll, closed: true } };
1284
+ }
1285
+ catch (error) {
1286
+ this.#sends?.record({ chatId, kind: "edit", outcome: "failed", messageId, errorCode: asCliError(error).code });
1287
+ throw error;
1288
+ }
1289
+ },
1290
+ /**
1291
+ * A new message whose one attachment is the poll — so it goes through `#deliver`, with its `cid`
1292
+ * and its one retry, like any message.
1293
+ */
1294
+ create: async (chatId, question, answers, options = {}) => {
1295
+ if (this.#offline)
1296
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot create a poll");
1297
+ if (question.trim() === "")
1298
+ throw new CliError("validation_error", "a poll needs a question");
1299
+ if (answers.length < 2 || answers.some((answer) => answer.trim() === "")) {
1300
+ throw new CliError("validation_error", "a poll needs at least two answers, none of them empty");
1301
+ }
1302
+ const cid = options.cid ?? this.#nextCid();
1303
+ const sendId = String(cid);
1304
+ try {
1305
+ await this.#checked({ chatId, kind: "message", sendId, key: "polls.create" });
1306
+ }
1307
+ catch (error) {
1308
+ this.#sends?.record({ chatId, kind: "message", outcome: "refused", sendId, errorCode: asCliError(error).code });
1309
+ throw error;
1310
+ }
1311
+ const attach = {
1312
+ _type: "POLL",
1313
+ title: question,
1314
+ answers: answers.map((text) => ({ text })),
1315
+ settings: pollSettings(options),
1316
+ };
1317
+ try {
1318
+ const sent = await this.#deliver(chatId, "", cid, {
1319
+ ...options,
1320
+ attaches: [attach],
1321
+ repeat: `max polls create ${chatId} …`,
1322
+ });
1323
+ this.#sends?.record({
1324
+ chatId,
1325
+ kind: "message",
1326
+ outcome: "sent",
1327
+ messageId: sent.id,
1328
+ sendId,
1329
+ length: question.length,
1330
+ });
1331
+ this.#warn("web.max.ru does not show polls: anyone reading this chat in a browser sees «Обновите MAX…»");
1332
+ return sent;
1333
+ }
1334
+ catch (error) {
1335
+ const failure = asCliError(error);
1336
+ this.#sends?.record({
1337
+ chatId,
1338
+ kind: "message",
1339
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
1340
+ sendId,
1341
+ errorCode: failure.code,
1342
+ });
1343
+ throw error;
1344
+ }
1345
+ },
1346
+ };
1347
+ /** The poll of one message, whole as MAX sends it and read, or a refusal that says why there is none. */
1348
+ async #poll(chatId, messageId) {
1349
+ const raw = await this.#rawMessage(chatId, messageId);
1350
+ const attach = asArray(raw.attaches).find((each) => each._type === "POLL");
1351
+ if (!attach)
1352
+ throw new CliError("validation_error", `message ${messageId} carries no poll`);
1353
+ const poll = toPoll(attach);
1354
+ if (!poll)
1355
+ throw new CliError("validation_error", `the poll of message ${messageId} is a newer kind this version cannot read`);
1356
+ if (poll.closed)
1357
+ throw new CliError("validation_error", `the poll of message ${messageId} is closed`);
1358
+ const lookup = { names: namesFrom(this.#session().contacts), ...viewer(this.#store) };
1359
+ return { attach, poll, outgoing: toMessage(raw, chatId, lookup).outgoing === true };
1360
+ }
1361
+ /** What the account has added, as web.max.ru reads it: the sections' set ids, then the sets, then their stickers. */
1362
+ stickers = {
1363
+ sets: async () => {
1364
+ await this.#connectOnce();
1365
+ const sections = asArray((await this.#wire.assets.update({ type: "STICKER", sync: 0 })).sections);
1366
+ const ids = [...new Set(sections.flatMap((section) => numbers(record(section)?.stickerSets)))];
1367
+ if (ids.length === 0)
1368
+ return [];
1369
+ const answer = await this.#wire.assets.byIds({ type: "STICKER_SET", ids });
1370
+ return asArray(answer.stickerSets).map((raw) => toStickerSet(record(raw) ?? {}));
1371
+ },
1372
+ list: async (setId) => {
1373
+ await this.#connectOnce();
1374
+ const [set] = asArray((await this.#wire.assets.byIds({ type: "STICKER_SET", ids: [Number(setId)] })).stickerSets);
1375
+ if (set === undefined)
1376
+ throw new CliError("not_found", `no sticker set ${setId} — \`stickers list\` names the sets`);
1377
+ const ids = numbers(record(set)?.stickers);
1378
+ if (ids.length === 0)
1379
+ return [];
1380
+ const answer = await this.#wire.assets.byIds({ type: "STICKER", ids });
1381
+ return asArray(answer.stickers).map((raw) => toSticker(record(raw) ?? {}));
1382
+ },
1383
+ };
1384
+ folders = {
1385
+ list: async () => (await this.#folders()).map(toFolder),
1386
+ create: (title, chats = []) => this.#change("folder-create", async () => {
1387
+ const include = await Promise.all(chats.map((chat) => this.chats.resolve(chat)));
1388
+ const answer = await this.#wire.folders.update({
1389
+ id: crypto.randomUUID(),
1390
+ title: folderTitle(title),
1391
+ include,
1392
+ filters: [],
1393
+ options: [],
1394
+ });
1395
+ return toFolder(record(answer.folder) ?? {});
1396
+ }),
1397
+ /**
1398
+ * **The folder goes back whole**, as the web client sends it: an edit that left out the chats
1399
+ * would empty the folder. What MAX keeps for itself — `sourceId`, `updateTime` — is not sent.
1400
+ */
1401
+ update: (reference, change) => this.#change("folder-update", async () => {
1402
+ const folder = pickFolder(reference, await this.#folders());
1403
+ const added = await Promise.all((change.add ?? []).map((chat) => this.chats.resolve(chat)));
1404
+ const removed = new Set(await Promise.all((change.remove ?? []).map((chat) => this.chats.resolve(chat))));
1405
+ const touchesChats = added.length > 0 || removed.size > 0;
1406
+ const current = toFolder(folder).chatIds;
1407
+ const include = [...new Set([...current, ...added])].filter((id) => !removed.has(id));
1408
+ const answer = await this.#wire.folders.update({
1409
+ id: String(folder.id),
1410
+ title: change.title === undefined ? String(folder.title ?? "") : folderTitle(change.title),
1411
+ ...(Array.isArray(folder.include) || touchesChats ? { include } : {}),
1412
+ filters: Array.isArray(folder.filters) ? folder.filters : [],
1413
+ options: Array.isArray(folder.options) ? folder.options : [],
1414
+ ...(Array.isArray(folder.favorites) ? { favorites: folder.favorites } : {}),
1415
+ });
1416
+ return toFolder(record(answer.folder) ?? {});
1417
+ }),
1418
+ /**
1419
+ * Every folder's id, in the new order. MAX keeps "all chats" first and refuses an order that
1420
+ * changes nothing (`folder.order.all-folder-not-first`, `folder.order.same`, measured 2026-10-08),
1421
+ * so the first is put back in front and the second is not sent.
1422
+ */
1423
+ order: (ids) => this.#change("folder-order", async () => {
1424
+ const order = ids.includes(ALL_CHATS_FOLDER)
1425
+ ? [ALL_CHATS_FOLDER, ...ids.filter((id) => id !== ALL_CHATS_FOLDER)]
1426
+ : ids;
1427
+ const current = (await this.#folders()).map((folder) => String(folder.id));
1428
+ if (order.join("\n") === current.join("\n"))
1429
+ return;
1430
+ await this.#wire.folders.reorder({ foldersOrder: order });
1431
+ }),
1432
+ /** The folder only — its chats stay where they are. */
1433
+ delete: (reference) => this.#change("folder-delete", async () => {
1434
+ const folder = pickFolder(reference, await this.#folders());
1435
+ await this.#wire.folders.delete({ folderIds: [String(folder.id)] });
1436
+ return toFolder(folder);
1437
+ }),
1438
+ };
1439
+ /**
1440
+ * A reaction is seen by the other person, so it goes through the send guard and the send log like a
1441
+ * message. Not retried: a reaction lost in transit costs a second command, and nothing about it is
1442
+ * measured to make a blind repeat safe.
1443
+ */
1444
+ async #reaction(chatId, messageId, call, key = "reactions.add") {
1445
+ if (this.#offline)
1446
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot react");
1447
+ try {
1448
+ await this.#checked({ chatId, kind: "reaction", key });
1449
+ }
1450
+ catch (error) {
1451
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "refused", errorCode: asCliError(error).code });
1452
+ throw error;
1453
+ }
1454
+ try {
1455
+ await this.#connectOnce();
1456
+ const answer = await call();
1457
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "sent", messageId });
1458
+ return toReactions(record(answer.reactionInfo) ?? {});
1459
+ }
1460
+ catch (error) {
1461
+ this.#sends?.record({ chatId, kind: "reaction", outcome: "failed", messageId, errorCode: asCliError(error).code });
1462
+ throw error;
1463
+ }
1464
+ }
1465
+ async #checked(request) {
1466
+ await this.#sends?.ask?.(request);
1467
+ this.#sends?.check(request);
1468
+ }
1469
+ /** Asks the send guard, and writes a refusal to the send journal before passing it on. */
1470
+ async #guard(request, messageId) {
1471
+ const { chatId, kind, notify } = request;
1472
+ try {
1473
+ await this.#checked(request);
1474
+ }
1475
+ catch (error) {
1476
+ this.#sends?.record({
1477
+ chatId,
1478
+ ...(kind ? { kind } : {}),
1479
+ ...(notify === undefined ? {} : { notify }),
1480
+ outcome: "refused",
1481
+ ...(messageId ? { messageId } : {}),
1482
+ errorCode: asCliError(error).code,
1483
+ });
1484
+ throw error;
1485
+ }
1486
+ }
1487
+ async #botWrite(action, uncertain, details) {
1488
+ try {
1489
+ await action();
1490
+ }
1491
+ catch (error) {
1492
+ const failure = asCliError(error);
1493
+ if (failure.code !== "timeout" && failure.code !== "network_error")
1494
+ throw error;
1495
+ throw new CliError("outcome_unknown", `${uncertain} (${failure.message}) — check the bot's reply before repeating this action`, { ...details, retryable: false });
1496
+ }
1497
+ }
1498
+ /** The other side of a one-to-one chat; a group has no single bot to start. */
1499
+ /**
1500
+ * The other side of a one-to-one chat. Its id is the two people's ids XORed (web.max.ru's
1501
+ * `resolveChatId`, and it matches the dialogs measured), so a chat the login has not listed yet —
1502
+ * a bot started from its link — still names its bot. A group's id is negative.
1503
+ */
1504
+ #botOf(chatId) {
1505
+ const viewerId = this.#store.readState().viewerId;
1506
+ if (chatId.startsWith("-") || viewerId === undefined)
1507
+ throw new CliError("validation_error", `chat ${chatId} is not a one-to-one chat with a bot`);
1508
+ return String(BigInt(viewerId) ^ BigInt(chatId));
1509
+ }
1510
+ /** One message as MAX sends it, for what the domain model drops — its attachments whole, its link. */
1511
+ async #rawMessage(chatId, messageId) {
1512
+ const time = timeOfMessageId(messageId);
1513
+ if (time === undefined)
1514
+ throw new CliError("validation_error", `"${messageId}" is not a message id`);
1515
+ const answer = await this.#wire.chats.history({
1516
+ chatId,
1517
+ from: time,
1518
+ forward: 0,
1519
+ backward: 1,
1520
+ getMessages: true,
1521
+ });
1522
+ const found = asArray(answer.messages)
1523
+ .map((raw) => record(raw) ?? {})
1524
+ .find((raw) => asId(raw.id) === messageId);
1525
+ if (!found)
1526
+ throw new CliError("not_found", `no message ${messageId} in chat ${chatId} — deleted, or in another chat`);
1527
+ return found;
1528
+ }
1529
+ async #deliver(chatId, text, cid, options) {
1530
+ const { text: plain, markup } = options.markdown ? parseMarkdown(text) : { text, markup: options.markup ?? [] };
1531
+ await this.#connectOnce();
1532
+ const session = this.#session();
1533
+ const attaches = [...(options.attaches ?? [])];
1534
+ for (const file of options.files ?? [])
1535
+ attaches.push(await this.#upload(file));
1536
+ // A forward carries no text or markup of its own — the web client leaves both out, and so was it measured.
1537
+ const content = options.forward
1538
+ ? { link: { type: "FORWARD", ...options.forward } }
1539
+ : {
1540
+ text: plain,
1541
+ elements: markup,
1542
+ ...(options.replyTo ? { link: { type: "REPLY", messageId: options.replyTo } } : {}),
1543
+ };
1544
+ const delayed = options.at === undefined ? {} : { delayedAttributes: { timeToFire: options.at } };
1545
+ const request = { chatId, message: { cid, attaches, ...content, ...delayed }, notify: options.notify ?? true };
1546
+ let answer;
1547
+ try {
1548
+ answer = await this.#untilAttachmentsReady(() => this.#wire.messages.send(request));
1549
+ }
1550
+ catch (error) {
1551
+ const failure = asCliError(error);
1552
+ if (failure.code !== "timeout" && failure.code !== "network_error")
1553
+ throw failure;
1554
+ if (options.at !== undefined) {
1555
+ throw new CliError("outcome_unknown", `the message may or may not have been scheduled (${failure.message}) — ` +
1556
+ `\`max messages scheduled ${chatId}\` shows the queue; sending it again could queue a second copy`, { sendId: cid });
1557
+ }
1558
+ // No answer came back, so MAX may already have delivered it. Repeating the identical `cid`
1559
+ // is what makes asking again safe rather than reckless.
1560
+ try {
1561
+ answer = await this.#wire.messages.send(request);
1562
+ }
1563
+ catch {
1564
+ throw new CliError("outcome_unknown", `the message may or may not have been sent (${failure.message}) — ` +
1565
+ `\`${options.repeat ?? "max messages send <chat> <text>"} --send-id ${cid}\` repeats the attempt without risking a second copy`, { sendId: cid });
1566
+ }
1567
+ }
1568
+ const sent = record(answer.message) ?? answer;
1569
+ return toMessage(sent, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) });
1570
+ }
1571
+ /**
1572
+ * **For a connection that stays open** (`max serve`). A one-shot command never calls these.
1573
+ */
1574
+ live = {
1575
+ /** The keep-alive the web client sends every 30 s; `interactive` is never true here. */
1576
+ ping: async () => {
1577
+ await this.#connectOnce();
1578
+ await this.#wire.session.ping({ interactive: false });
1579
+ },
1580
+ /**
1581
+ * **The reads a web tab sends after every login** (`MAX-52`, recorded 2026-09-25), in its order
1582
+ * and all at once, as it sends them: folders, banners, call history, then the four asset sets.
1583
+ * Each carries the sync value its previous answer returned — 0 the first time — and the answers
1584
+ * are kept only for those. `max serve` alone sends them; a one-shot command never did.
1585
+ */
1586
+ readLikeTab: async (sync) => {
1587
+ await this.#connectOnce();
1588
+ const folders = this.#wire.folders.list({ folderSync: sync.folders });
1589
+ const banners = this.#wire.banners.list({ bannersSync: 0 });
1590
+ const calls = this.#wire.calls.history({ callHistorySync: sync.calls });
1591
+ const assets = ASSET_TYPES.map((type) => this.#wire.assets.update({ type, sync: sync.assets[type] ?? 0 }));
1592
+ const [folderAnswer, , callAnswer, answers] = await Promise.all([folders, banners, calls, Promise.all(assets)]);
1593
+ return {
1594
+ folders: numberOr(folderAnswer.folderSync, sync.folders),
1595
+ calls: numberOr(callAnswer.callHistorySync, sync.calls),
1596
+ assets: Object.fromEntries(ASSET_TYPES.map((type, at) => [type, numberOr(answers[at]?.sync, sync.assets[type] ?? 0)])),
1597
+ };
1598
+ },
1599
+ /**
1600
+ * What a hidden web tab reports once, 20 s after it opened: the chat list, shown at `at`.
1601
+ * `sessionId` is when the tab's connection began, and it survives the tab's reconnects.
1602
+ */
1603
+ chatListShown: async ({ at, sessionId }) => {
1604
+ const viewerId = this.#store.readState().viewerId;
1605
+ if (!viewerId)
1606
+ return;
1607
+ await this.#connectOnce();
1608
+ await this.#wire.session.log({
1609
+ events: [
1610
+ {
1611
+ type: "NAV",
1612
+ userId: viewerId,
1613
+ time: at,
1614
+ sessionId,
1615
+ event: "GO",
1616
+ params: { action_id: 1, screen_to: 150, prev_time: 0, source_id: viewerId },
1617
+ },
1618
+ ],
1619
+ });
1620
+ },
1621
+ /**
1622
+ * The login this connection holds, kept current from what MAX pushes — `max serve` hands it to
1623
+ * a command as that command's own login.
1624
+ */
1625
+ snapshot: () => this.#session(),
1626
+ /**
1627
+ * What the next login on a new connection sends to pick up where this one is (`MAX-51`):
1628
+ * `undefined` when this login lacks what that needs, and the next one is then a full login.
1629
+ */
1630
+ resumeFrom: () => {
1631
+ const login = this.#login;
1632
+ const configHash = record(login?.config)?.hash;
1633
+ if (!login || typeof login.time !== "number" || typeof configHash !== "string")
1634
+ return undefined;
1635
+ const chats = asArray(login.chats).map((chat) => record(chat) ?? {});
1636
+ const chatsSync = Math.max(0, ...chats.map(eventTime));
1637
+ return { login: { lastLogin: login.time, chatsSync, configHash }, chats };
1638
+ },
1639
+ /**
1640
+ * Keeps the snapshot current from one push. **`false` means it can no longer be trusted** and
1641
+ * the server stops handing it out until it has logged in again. What web.max.ru does with each
1642
+ * push (bundle read 2026-09-24) is what is copied here:
1643
+ * - 128, a new message: the chat's last message and time move; unread goes up for somebody
1644
+ * else's message and to zero for our own, as the app does once you have written in a chat.
1645
+ * - 130, read up to a point: when we are the reader, the chat's unread becomes `unread`;
1646
+ * somebody else reading changes nothing here.
1647
+ * - 135, a chat changed: MAX sends the whole chat, and it replaces ours.
1648
+ * - 134, the settings changed elsewhere — a mute, the privacy: `{config: {hash, user | chats}}`, folded in
1649
+ * as our own change's answer is (measured 2026-10-08 with a second connection).
1650
+ * Anything else that touches the chats — a deletion — cannot be followed.
1651
+ */
1652
+ patch: (opcode, payload) => {
1653
+ const chats = asArray(this.#session().chats);
1654
+ const chatId = asId(payload.chatId);
1655
+ const chat = chats.find((candidate) => asId(candidate.id) === chatId);
1656
+ const viewerId = this.#store.readState().viewerId;
1657
+ if (opcode === NEW_MESSAGE) {
1658
+ const raw = record(payload.message);
1659
+ if (!raw || !chat)
1660
+ return false;
1661
+ // A rename arrives as a service message that carries the chat whole (measured 2026-10-08).
1662
+ const whole = record(payload.chat);
1663
+ if (whole && asId(whole.id) === chatId)
1664
+ Object.assign(chat, whole);
1665
+ const ours = viewerId !== undefined && asId(raw.sender) === viewerId;
1666
+ chat.lastMessage = raw;
1667
+ chat.lastEventTime = raw.time;
1668
+ chat.newMessages = ours ? 0 : (typeof chat.newMessages === "number" ? chat.newMessages : 0) + 1;
1669
+ return true;
1670
+ }
1671
+ if (opcode === READ_MARK) {
1672
+ if (asId(payload.userId) !== viewerId)
1673
+ return true;
1674
+ if (!chat || typeof payload.unread !== "number")
1675
+ return false;
1676
+ chat.newMessages = payload.unread;
1677
+ return true;
1678
+ }
1679
+ if (opcode === CONFIG_CHANGED) {
1680
+ const config = record(payload.config);
1681
+ if (!config)
1682
+ return false;
1683
+ this.#keepSettings({ user: record(config.user), chats: record(config.chats) }, config);
1684
+ return true;
1685
+ }
1686
+ if (opcode === CHAT_CHANGED) {
1687
+ const changed = record(payload.chat);
1688
+ const id = changed && asId(changed.id);
1689
+ if (!changed || id === undefined)
1690
+ return false;
1691
+ const at = chats.findIndex((candidate) => asId(candidate.id) === id);
1692
+ if (at >= 0)
1693
+ chats[at] = changed;
1694
+ // Past this, a new login rebuilds the list rather than pushes growing it without end.
1695
+ else if (chats.length >= LIVE_CHATS)
1696
+ return false;
1697
+ else
1698
+ chats.push(changed);
1699
+ this.#session().chats = chats;
1700
+ return true;
1701
+ }
1702
+ return !CHANGES_CHATS.has(opcode);
1703
+ },
1704
+ /**
1705
+ * Whether a chat sent whole says something the held copy does not: a chat not held, or another
1706
+ * title, description, member count, status, type, photo or owner. The rest — its last message, its
1707
+ * unread count — changes with every message and is not a change to the chat.
1708
+ */
1709
+ news: (changed) => {
1710
+ const id = asId(changed.id);
1711
+ const held = asArray(this.#session().chats).find((candidate) => asId(candidate.id) === id);
1712
+ return !held || CHAT_FACTS.some((key) => JSON.stringify(held[key]) !== JSON.stringify(changed[key]));
1713
+ },
1714
+ /** A contact MAX answered with after our own change to it replaces the login's copy. */
1715
+ contact: (changed) => {
1716
+ const id = asId(changed.id);
1717
+ const contacts = asArray(this.#session().contacts);
1718
+ const at = contacts.findIndex((candidate) => asId(candidate.id) === id);
1719
+ if (id === undefined || at < 0)
1720
+ return;
1721
+ contacts[at] = changed;
1722
+ this.#session().contacts = contacts;
1723
+ },
1724
+ /** One request from a command, on this connection. The server decides which ones may pass. */
1725
+ forward: async (opcode, payload) => {
1726
+ await this.#connectOnce();
1727
+ return this.#connection.invoke(opcode, payload);
1728
+ },
1729
+ /**
1730
+ * A message MAX pushed (opcode 128) as the same shape `messages list` prints, with its chat's
1731
+ * name — so whoever reads `max watch` gets one schema, and no MAX type crosses this file.
1732
+ * Anything else pushed answers `undefined`.
1733
+ */
1734
+ message: async (opcode, payload) => {
1735
+ const raw = record(payload.message);
1736
+ if (opcode !== NEW_MESSAGE || !raw || raw.status !== undefined)
1737
+ return undefined;
1738
+ return this.#messageHit(payload);
1739
+ },
1740
+ /** An edit, a deletion or a reaction pushed by MAX, or `undefined` for anything else. */
1741
+ change: async (opcode, payload) => {
1742
+ const whole = record(payload.chat);
1743
+ if (opcode === CHAT_CHANGED || (opcode === NEW_MESSAGE && whole)) {
1744
+ return whole && asId(whole.id) !== undefined ? { event: "chat", chat: toChat(whole) } : undefined;
1745
+ }
1746
+ const chatId = asId(payload.chatId);
1747
+ if (chatId === undefined)
1748
+ return undefined;
1749
+ if (opcode === READ_MARK) {
1750
+ const userId = asId(payload.userId);
1751
+ // The same opcode marks a chat unread again; that is not somebody reading it.
1752
+ if (userId === undefined || payload.setAsUnread === true)
1753
+ return undefined;
1754
+ return {
1755
+ event: "read",
1756
+ chatId,
1757
+ chatTitle: await this.#chatTitle(chatId),
1758
+ userId,
1759
+ upToTime: typeof payload.mark === "number" && payload.mark > 0 ? new Date(payload.mark).toISOString() : null,
1760
+ unreadCount: typeof payload.unread === "number" ? payload.unread : null,
1761
+ };
1762
+ }
1763
+ const raw = record(payload.message);
1764
+ if (opcode === NEW_MESSAGE && raw?.status === "EDITED") {
1765
+ const message = await this.#messageHit(payload);
1766
+ return message && { event: "edit", message };
1767
+ }
1768
+ const messageId = asId(opcode === NEW_MESSAGE ? raw?.id : payload.messageId);
1769
+ if (messageId === undefined)
1770
+ return undefined;
1771
+ if (opcode === NEW_MESSAGE && raw?.status === "REMOVED") {
1772
+ return { event: "delete", chatId, chatTitle: await this.#chatTitle(chatId), messageId };
1773
+ }
1774
+ if (opcode === REACTIONS_CHANGED) {
1775
+ const reactions = toReactions(payload);
1776
+ return { event: "reaction", chatId, chatTitle: await this.#chatTitle(chatId), messageId, reactions };
1777
+ }
1778
+ return undefined;
1779
+ },
1780
+ };
1781
+ async #messageHit(payload) {
1782
+ const raw = record(payload.message);
1783
+ const chatId = asId(payload.chatId);
1784
+ if (!raw || chatId === undefined)
1785
+ return undefined;
1786
+ const session = this.#session();
1787
+ const [message] = await this.#nameSenders([
1788
+ toMessage(raw, chatId, { names: namesFrom(session.contacts), ...viewer(this.#store) }),
1789
+ ]);
1790
+ return message && { ...message, chatTitle: await this.#chatTitle(chatId) };
1791
+ }
1792
+ async #chatTitle(chatId) {
1793
+ return (await this.chats.list()).items.find((candidate) => candidate.id === chatId)?.title ?? null;
1794
+ }
1795
+ /**
1796
+ * Opens the connection and logs in with the stored token, or with one offered for trial.
1797
+ *
1798
+ * The login response carries the profile, the chats, the contacts and recent messages, so most
1799
+ * commands need no further request — measured against MAX on 2026-09-19.
1800
+ *
1801
+ * **A candidate token is tried and not stored**, which is what lets `adoptToken` write to the
1802
+ * keyring only after MAX has accepted it. Nothing here writes a token; see `session/adopt.ts`.
1803
+ */
1804
+ async connect({ token: candidate } = {}) {
1805
+ if (this.#offline)
1806
+ throw new CliError("validation_error", "`--offline` reads through the shared store; it cannot connect to MAX");
1807
+ const token = candidate ?? this.#store.readToken();
1808
+ if (!token) {
1809
+ const profile = this.#store.profile;
1810
+ // cli-core swallows a keyring that will not open, so the state file is the only witness: a
1811
+ // profile that has logged in lost its keyring, not its session, and another login would
1812
+ // register one more device for nothing (MAX-50, measured from cron 2026-09-25).
1813
+ if (this.#store.hasLoggedIn()) {
1814
+ throw new CliError("authentication_error", `no token found for profile "${profile}", although it has logged in on this machine — ` +
1815
+ `the keyring is probably out of reach (cron, ssh: set XDG_RUNTIME_DIR); ` +
1816
+ `\`max ${asFirstWord(profile)}doctor\` shows it. Log in again only if the token was removed`);
1817
+ }
1818
+ if (this.#store.isBot()) {
1819
+ throw new CliError("authentication_error", `profile "${profile}" is a bot — its commands are \`max ${asFirstWord(profile)}bot …\`; ` +
1820
+ `\`max ${asFirstWord(profile)}session start\` would add a personal account to it`);
1821
+ }
1822
+ // The fix has to carry the profile, or it logs the wrong one in: a name nobody has logged
1823
+ // in under is the ordinary shape of this failure now that the first word is the profile.
1824
+ throw new CliError("authentication_error", `no session for profile "${profile}" — run \`max ${asFirstWord(profile)}setup\` in a local terminal; agents: read \`max skill show\``);
1825
+ }
1826
+ const state = this.#store.readState();
1827
+ refuseWhilePaused(state);
1828
+ const sync = this.#fullLogin ? undefined : await this.#loginMarker();
1829
+ try {
1830
+ await this.#connection.open();
1831
+ this.#login = await startSession(this.#invoke, {
1832
+ token,
1833
+ deviceId: state.deviceId,
1834
+ ...(sync === undefined ? {} : { sync }),
1835
+ ...(this.#resume ? { resume: this.#resume.login } : {}),
1836
+ });
1837
+ }
1838
+ catch (error) {
1839
+ const failure = asCliError(error);
1840
+ if (failure.code !== "rate_limited")
1841
+ throw failure;
1842
+ const paused = withLoginRefused(state);
1843
+ this.#store.writeState(paused);
1844
+ throw new CliError("rate_limited", `${failure.message} — this profile will not log in again before ${paused.loginPausedUntil}; ` +
1845
+ "logging in sooner is what keeps an account locked");
1846
+ }
1847
+ const viewerId = toProfile(record(this.#login.profile) ?? {}).id;
1848
+ // **Before `writeState`**, so a login we are about to refuse does not count itself or move
1849
+ // `lastLoginAt`. A profile is a person, not a directory name: if this token belongs to someone
1850
+ // else, every later command in this process would send as them and nothing would say so.
1851
+ // Switching accounts has a door already — `session end` clears the id along with the token.
1852
+ if (viewerId && state.viewerId && viewerId !== state.viewerId) {
1853
+ throw new CliError("authentication_error", `this token is for a different account than profile "${this.#store.profile}" was set up with — ` +
1854
+ `run \`max ${asFirstWord(this.#store.profile)}session end\` first if you meant to switch`);
1855
+ }
1856
+ this.#store.writeState({
1857
+ ...withoutLoginPause(state),
1858
+ ...(viewerId ? { viewerId } : {}),
1859
+ logins: state.logins + 1,
1860
+ lastLoginAt: new Date().toISOString(),
1861
+ });
1862
+ this.#keepRotatedToken(token);
1863
+ // `max serve` hands this login to any process that asks its socket; none of them needs the token.
1864
+ const { token: _, ...withoutToken } = this.#login;
1865
+ this.#login = withoutToken;
1866
+ if (this.#resume)
1867
+ this.#mergeChanged(this.#resume.chats);
1868
+ else
1869
+ await this.#readRestOfChats();
1870
+ await this.#mergeLogin(viewerId);
1871
+ }
1872
+ /**
1873
+ * A login with `chatsSync` answers only the chats that changed since it (measured 2026-09-20), so
1874
+ * they go over the list the previous connection held: same id replaced, the rest kept, newest first.
1875
+ */
1876
+ #mergeChanged(before) {
1877
+ const session = this.#session();
1878
+ const changed = asArray(session.chats);
1879
+ const ids = new Set(changed.map((chat) => asId(record(chat)?.id)));
1880
+ const kept = before.filter((chat) => !ids.has(asId(chat.id)));
1881
+ session.chats = [...changed, ...kept].sort((a, b) => eventTime(b) - eventTime(a));
1882
+ }
1883
+ /**
1884
+ * **The chats LOGIN left out, as the web tab reads them:** one `CHATS_LIST` from the last-activity
1885
+ * time of the oldest chat it sent, 2.3 s after the login answer (capture 2026-09-25). Measured the
1886
+ * same day: that answers exactly the chats after it, and one answer held 26. Only one request,
1887
+ * because the tab sends only one — `#chatsCut` says when that may not have been all.
1888
+ *
1889
+ * A refusal leaves the login's chats as they were: a shorter list beats a failed command.
1890
+ */
1891
+ async #readRestOfChats() {
1892
+ this.#chatsCut = false;
1893
+ this.#chatsComplete = false;
1894
+ const session = this.#session();
1895
+ const chats = asArray(session.chats);
1896
+ const marker = record(chats.at(-1))?.lastEventTime;
1897
+ if (chats.length < LOGIN_CHATS) {
1898
+ this.#chatsComplete = true;
1899
+ return;
1900
+ }
1901
+ if (typeof marker !== "number" && typeof marker !== "bigint")
1902
+ return;
1903
+ try {
1904
+ const answer = await this.#wire.chats.list({ marker: Number(marker) });
1905
+ const known = new Set(chats.map((chat) => asId(record(chat)?.id)));
1906
+ const rest = asArray(answer.chats).filter((chat) => !known.has(asId(record(chat)?.id)));
1907
+ session.chats = [...chats, ...rest];
1908
+ this.#chatsCut = rest.length >= CHATS_PAGE_SEEN;
1909
+ this.#chatsComplete = !this.#chatsCut;
1910
+ }
1911
+ catch (error) {
1912
+ this.#chatsCut = true;
1913
+ this.#warnAbout("chats_partial", `only the newest ${chats.length} chats were read: ${reasonOf(error)}`);
1914
+ }
1915
+ }
1916
+ /**
1917
+ * **MAX offers a replacement for a credential that has aged, and until 2026-09-22 we threw it
1918
+ * away** — measured, `pnpm probe:token`. Presenting a token pasted months earlier answers with a
1919
+ * different one; presenting *that* one answers with the same one back, so this writes once and
1920
+ * then stops rather than on every command.
1921
+ *
1922
+ * The old token keeps working — the last months are the proof — so this is hygiene, not repair.
1923
+ *
1924
+ * ⚠ **After the identity refusal above, never before it.** A token belonging to somebody else
1925
+ * reaches this method only if that check has already let it through; persisting it earlier would
1926
+ * overwrite the owner's working credential with a stranger's — a worse version of the defect
1927
+ * `MAX-12` exists to fix.
1928
+ *
1929
+ * **A keyring that will not take it does not fail the command.** The write can fail for reasons
1930
+ * that have nothing to do with the command being run — a locked keyring, no session bus — and
1931
+ * the old token still works, which is what makes carrying on correct. The reason goes to the
1932
+ * diagnostic stream, because failing quietly is not failing invisibly (`NEED-97`).
1933
+ *
1934
+ * Nothing here prints, returns or compares-aloud the value: the only comparison is against the
1935
+ * token we sent, and the only thing that leaves is whether a write failed.
1936
+ */
1937
+ #keepRotatedToken(sent) {
1938
+ const rotated = this.#session().token;
1939
+ if (typeof rotated !== "string" || rotated === "" || rotated === sent)
1940
+ return;
1941
+ if (this.#fromEnvironment()) {
1942
+ this.#warnAbout("token_not_saved", "MAX refreshed the session; the fresh token was not saved, because the one in use came from MAX_TOKEN");
1943
+ return;
1944
+ }
1945
+ try {
1946
+ this.#store.writeToken(rotated);
1947
+ }
1948
+ catch (error) {
1949
+ this.#warnAbout("token_not_saved", `the refreshed session could not be saved, so the previous one is still in use: ${reasonOf(error)}`);
1950
+ }
1951
+ }
1952
+ /**
1953
+ * Writes what the login just told us into the store, **before the command renders**, so a
1954
+ * listing prints the names this run brought rather than the previous run's.
1955
+ *
1956
+ * ⚠ **A delta is mostly empty and that is correct.** After the first login MAX sends only what
1957
+ * changed, so an absent person is an unchanged person — never a departed one. Nothing here
1958
+ * deletes, with the single exception of a chat's membership, which MAX restates in full whenever
1959
+ * it sends that chat at all.
1960
+ *
1961
+ * **It never fails the command.** Nobody asked for the store; a locked database or a full disk
1962
+ * must not lose an answer MAX has already given. The reason goes to the diagnostic stream,
1963
+ * because failing quietly is not the same as failing invisibly (`NEED-97`) — and the marker
1964
+ * stays where it was, so the next login asks for the same delta again rather than for changes
1965
+ * since rows that were never written.
1966
+ */
1967
+ async #mergeLogin(viewerId) {
1968
+ const marker = asMarker(this.#session().time);
1969
+ if (!this.#record)
1970
+ return;
1971
+ const chats = asArray(this.#session().chats);
1972
+ const members = new Map();
1973
+ for (const raw of chats) {
1974
+ const chat = toChat(raw);
1975
+ // A channel lists four of its hundred and seventy-eight thousand subscribers, so its
1976
+ // `participants` is not a membership — storing it would be storing a wrong answer.
1977
+ if (!chat.id || chat.kind === "channel")
1978
+ continue;
1979
+ const participants = this.#participantsOf(raw, viewerId);
1980
+ if (participants !== undefined)
1981
+ members.set(chat.id, participants);
1982
+ }
1983
+ const delta = {
1984
+ chats: chats.map(toChat).filter((chat) => chat.id !== ""),
1985
+ people: asArray(this.#session().contacts)
1986
+ .map(toContact)
1987
+ .filter((contact) => contact.id !== ""),
1988
+ members,
1989
+ };
1990
+ // A login `max serve` made answered its own marker, not the record's: what changed before it is not in it.
1991
+ const own = this.server?.journals !== true;
1992
+ await this.#keep("login", async (record) => {
1993
+ const summary = await record.applyLogin({ ...delta, ...(own && marker !== undefined ? { marker } : {}) });
1994
+ this.#merged = summary;
1995
+ });
1996
+ }
1997
+ async #loginMarker() {
1998
+ let marker;
1999
+ await this.#keep("marker", async (record) => {
2000
+ marker = await record.syncMarker();
2001
+ });
2002
+ return marker;
2003
+ }
2004
+ /** A failed record never loses an answer MAX already gave. */
2005
+ async #keep(what, write) {
2006
+ if (!this.#record)
2007
+ return;
2008
+ try {
2009
+ await write(this.#record);
2010
+ }
2011
+ catch (error) {
2012
+ this.#warnAbout("cache_not_written", `the local store did not take the ${what}: ${reasonOf(error)}`);
2013
+ }
2014
+ }
2015
+ #participantsOf(chat, viewerId) {
2016
+ const participants = record(chat.participants);
2017
+ return participants === undefined ? undefined : Object.keys(participants).filter((id) => id !== viewerId);
2018
+ }
2019
+ /** Ends this session on MAX's side; the token stops working everywhere it was copied to. */
2020
+ async logout() {
2021
+ if (this.#offline)
2022
+ throw new CliError("validation_error", "`--offline` cannot log out on MAX's side");
2023
+ await this.#connectOnce();
2024
+ await this.#wire.session.logout({});
2025
+ }
2026
+ async close() {
2027
+ await this.#connection.close();
2028
+ }
2029
+ /**
2030
+ * Never `CHAT_MARK`: reading history must not mark anything read (§19). Without `interactive`, as
2031
+ * the web client asks — measured 2026-09-25 on a channel with 8 unread: reading moved nothing.
2032
+ */
2033
+ async #history(chatId, window, { reactions = true } = {}) {
2034
+ await this.#connectOnce();
2035
+ const session = this.#session();
2036
+ const answer = await this.#wire.chats.history({
2037
+ chatId,
2038
+ ...window,
2039
+ getMessages: true,
2040
+ });
2041
+ const lookup = { names: namesFrom(session.contacts), ...viewer(this.#store) };
2042
+ const messages = await this.#nameSenders(asArray(answer.messages).map((raw) => toMessage(raw, chatId, lookup)));
2043
+ return reactions ? this.#withReactions(chatId, messages) : messages;
2044
+ }
2045
+ /** Straight to the wire, as `#olderThan`: one message's id is all a gallery read needs to start from. */
2046
+ async #newestId(chatId) {
2047
+ const answer = await this.#wire.chats.history({
2048
+ chatId,
2049
+ from: Date.now(),
2050
+ forward: 0,
2051
+ backward: 1,
2052
+ getMessages: true,
2053
+ });
2054
+ return asId(record(asArray(answer.messages).at(-1))?.id);
2055
+ }
2056
+ /** Straight to the wire: the reactions and names `#history` fetches are not wanted for a yes or no. */
2057
+ async #olderThan(chatId, messages) {
2058
+ const oldest = Math.min(...messages.map((message) => Date.parse(message.timestamp)));
2059
+ const answer = await this.#wire.chats.history({
2060
+ chatId,
2061
+ from: oldest - 1,
2062
+ forward: 0,
2063
+ backward: 1,
2064
+ getMessages: true,
2065
+ });
2066
+ return asArray(answer.messages).length > 0;
2067
+ }
2068
+ /**
2069
+ * One request per page, which also says which reaction is this account's. History carries the counts only in
2070
+ * a channel (measured 2026-10-06); ~~history carries no reactions (measured 2026-09-23)~~ held for other chats.
2071
+ */
2072
+ async #withReactions(chatId, messages) {
2073
+ if (messages.length === 0)
2074
+ return messages;
2075
+ try {
2076
+ const answer = await this.#wire.messages.reactions({ chatId, messageIds: messages.map((message) => message.id) });
2077
+ const byId = record(answer.messagesReactions) ?? {};
2078
+ return messages.map((message) => {
2079
+ const raw = record(byId[message.id]);
2080
+ return { ...message, reactions: raw ? toReactions(raw) : { counts: [], mine: null, total: 0 } };
2081
+ });
2082
+ }
2083
+ catch (error) {
2084
+ this.#warnAbout("reactions_unread", `reactions are not shown: they could not be read (${reasonOf(error)})`);
2085
+ return messages;
2086
+ }
2087
+ }
2088
+ /**
2089
+ * **A group member who is not a contact arrives as a bare id** — the login names contacts only,
2090
+ * so in a group chat everyone but the owner showed as a number (`FIND-45`). The names we hold
2091
+ * answer first; the rest cost one `CONTACT_INFO` per hundred, and are kept so the next read
2092
+ * does not ask again. Quoted senders in replies and forwards are named the same way.
2093
+ *
2094
+ * A refused lookup costs the names, not the read: the messages are what was asked for.
2095
+ */
2096
+ async #nameSenders(messages) {
2097
+ const quoted = messages.flatMap((message) => [message.replyTo, message.forwardedFrom]).filter(isPresent);
2098
+ const ids = [...new Set([...messages, ...quoted].filter(needsName).map((message) => message.senderId))];
2099
+ if (ids.length === 0)
2100
+ return messages;
2101
+ const names = await this.#namesOf(ids);
2102
+ const name = (message) => needsName(message) && names.has(message.senderId)
2103
+ ? { ...message, senderName: names.get(message.senderId) ?? null }
2104
+ : message;
2105
+ return messages.map((message) => ({
2106
+ ...name(message),
2107
+ replyTo: message.replyTo && name(message.replyTo),
2108
+ forwardedFrom: message.forwardedFrom && name(message.forwardedFrom),
2109
+ }));
2110
+ }
2111
+ /** The chat's owner and admins as the login carried them; `undefined` when it did not. */
2112
+ async #members(reference, { limit, offset } = { offset: 0 }) {
2113
+ if (this.#offline)
2114
+ throw new CliError("validation_error", "`--offline` has no member list; MAX has");
2115
+ const chatId = await this.chats.resolve(reference);
2116
+ await this.#connectOnce();
2117
+ const members = new Map();
2118
+ const target = limit === undefined ? MEMBERS_READ : Math.min(MEMBERS_READ, offset + limit + 1);
2119
+ let marker = 0;
2120
+ let complete = false;
2121
+ while (members.size < target) {
2122
+ const answer = await this.#wire.chats.members({ chatId, type: "MEMBER", marker, count: MEMBERS_PAGE });
2123
+ const before = members.size;
2124
+ for (const raw of asArray(answer.members)) {
2125
+ const member = toGroupMember(raw);
2126
+ if (member.id)
2127
+ members.set(member.id, member);
2128
+ }
2129
+ const next = typeof answer.marker === "number" ? answer.marker : 0;
2130
+ if (!next || next === marker || members.size === before) {
2131
+ complete = !next;
2132
+ break;
2133
+ }
2134
+ marker = next;
2135
+ }
2136
+ const roles = await this.#roles(chatId);
2137
+ const role = (id) => (id === roles?.owner ? "owner" : roles?.admins.has(id) ? "admin" : "member");
2138
+ const end = limit === undefined ? members.size : offset + limit;
2139
+ return {
2140
+ chatId,
2141
+ members: [...members.values()]
2142
+ .slice(offset, end)
2143
+ .map((member) => (roles ? { ...member, role: role(member.id) } : member)),
2144
+ complete,
2145
+ rolesKnown: roles !== undefined,
2146
+ hasMore: end < members.size,
2147
+ truncated: !complete && (members.size < target || members.size >= MEMBERS_READ),
2148
+ readCount: members.size,
2149
+ };
2150
+ }
2151
+ async #roles(chatId) {
2152
+ await this.#connectOnce();
2153
+ const raw = asArray(this.#session().chats).find((chat) => asId(chat.id) === chatId);
2154
+ if (!raw || (raw.owner === undefined && raw.admins === undefined && raw.adminParticipants === undefined)) {
2155
+ return undefined;
2156
+ }
2157
+ const admins = [
2158
+ ...(Array.isArray(raw.admins) ? raw.admins.map(asId) : []),
2159
+ ...Object.keys(record(raw.adminParticipants) ?? {}),
2160
+ ].filter((id) => id !== undefined);
2161
+ const owner = asId(raw.owner);
2162
+ return { ...(owner ? { owner } : {}), admins: new Set(admins.filter((id) => id !== owner)) };
2163
+ }
2164
+ /** Names we hold first, the rest from `CONTACT_INFO`, kept for next time. A refusal costs the names only. */
2165
+ async #namesOf(ids) {
2166
+ const names = new Map();
2167
+ await this.#keep("names", async (record) => {
2168
+ for (const [id, name] of await record.names(ids))
2169
+ names.set(id, name);
2170
+ });
2171
+ const fetched = [];
2172
+ try {
2173
+ for (const batch of batched(ids.filter((id) => !names.has(id)), CONTACT_INFO_BATCH)) {
2174
+ const answer = await this.#wire.contacts.info({ contactIds: batch });
2175
+ for (const raw of asArray(answer.contacts)) {
2176
+ const contact = toContact(raw);
2177
+ if (!contact.id || !contact.name)
2178
+ continue;
2179
+ names.set(contact.id, contact.name);
2180
+ fetched.push(contact);
2181
+ }
2182
+ }
2183
+ }
2184
+ catch (error) {
2185
+ this.#warnAbout("names_unread", `some people are shown by id: their names could not be looked up (${reasonOf(error)})`);
2186
+ }
2187
+ await this.#keep("names", (record) => record.remember(fetched));
2188
+ return names;
2189
+ }
2190
+ /** INIT with the profile's own device, which is the device MAX then issues the token to (bite 8). */
2191
+ async #beforeLogin(flow) {
2192
+ try {
2193
+ await this.#connection.open();
2194
+ }
2195
+ catch (error) {
2196
+ throw asCliError(error);
2197
+ }
2198
+ await this.#wire.session.init({ userAgent: WEB_USER_AGENT, deviceId: this.#store.readState().deviceId });
2199
+ return await flow();
2200
+ }
2201
+ async #connectOnce() {
2202
+ if (!this.#login)
2203
+ await this.connect();
2204
+ }
2205
+ /**
2206
+ * One request: encode against the specification, send, and compare the answer with what we said
2207
+ * it would be.
2208
+ *
2209
+ * **The comparison never decides anything** (`NEED-35`). A response that gained a field is not a
2210
+ * mismatch at all — response shapes are loose. What is left is a field we rely on that stopped
2211
+ * being what it was, which is worth one line on stderr and nothing more.
2212
+ */
2213
+ async #send(operation, request) {
2214
+ const payload = buildRequest(operation, request);
2215
+ const started = performance.now();
2216
+ const about = { operation: operation.name, opcode: operation.opcode };
2217
+ let seq = 0;
2218
+ let bytes;
2219
+ let answer;
2220
+ try {
2221
+ answer = await this.#connection.invoke(operation.opcode, payload, (wire) => {
2222
+ seq = wire.seq;
2223
+ if (wire.phase === "received") {
2224
+ bytes = wire.bytes;
2225
+ return;
2226
+ }
2227
+ this.#emit({ event: "request", ...about, seq, bytes: wire.bytes, ...idsOf(request) });
2228
+ });
2229
+ }
2230
+ catch (error) {
2231
+ const failure = asCliError(error);
2232
+ // The request that never came back is the one somebody is reading the log for, so it gets a
2233
+ // line of its own rather than disappearing with the exception.
2234
+ this.#emit({
2235
+ event: "response",
2236
+ ...about,
2237
+ seq,
2238
+ ...(bytes === undefined ? {} : { bytes }),
2239
+ durationMs: Math.round(performance.now() - started),
2240
+ outcome: "error",
2241
+ errorCode: failure.code,
2242
+ ...(typeof failure.details.providerError === "string" ? { providerError: failure.details.providerError } : {}),
2243
+ });
2244
+ throw failure;
2245
+ }
2246
+ const counts = countsIn(answer);
2247
+ this.#emit({
2248
+ event: "response",
2249
+ ...about,
2250
+ seq,
2251
+ ...(bytes === undefined ? {} : { bytes }),
2252
+ durationMs: Math.round(performance.now() - started),
2253
+ ...(counts ? { counts } : {}),
2254
+ outcome: "ok",
2255
+ });
2256
+ const note = checkResponse(operation, answer);
2257
+ if (note)
2258
+ this.#warnAbout("response_shape", note, { operation: operation.name, detail: note });
2259
+ return answer;
2260
+ }
2261
+ /** A token from `MAX_TOKEN` belongs to whoever set it: nothing MAX hands back replaces what is stored. */
2262
+ #fromEnvironment() {
2263
+ try {
2264
+ return this.#store.tokenSource() === "environment";
2265
+ }
2266
+ catch {
2267
+ return false;
2268
+ }
2269
+ }
2270
+ /** The sentence to the person, and only the code to the log (`WarningEvent`). */
2271
+ #warnAbout(code, message, extra = {}) {
2272
+ this.#warn(message);
2273
+ this.#emit({ event: "warning", code, ...extra });
2274
+ }
2275
+ /** A diagnostic that breaks the command it was describing is worse than no diagnostic. */
2276
+ #emit(event) {
2277
+ try {
2278
+ this.#events(event);
2279
+ }
2280
+ catch {
2281
+ // Whoever is keeping the log has a problem; the command does not.
2282
+ }
2283
+ }
2284
+ /** A group's photo goes up as a message photo does; the web client asks `{count: 1}` for it (2026-10-08). */
2285
+ async #photoToken(photo, { profile }) {
2286
+ const path = typeof photo === "string" ? photo : photo.name;
2287
+ if (!isImage(path))
2288
+ throw new CliError("validation_error", `${path} is not an image MAX takes as a photo`);
2289
+ const bytes = typeof photo === "string" ? await readUpload(photo) : Buffer.from(photo.bytes);
2290
+ const { url } = await this.#wire.uploads.photo({ count: 1, type: 0, uploaderType: 0, profile });
2291
+ if (typeof url !== "string")
2292
+ throw new CliError("provider_error", "MAX gave no address to upload the photo to");
2293
+ return uploadPhoto(url, path, bytes);
2294
+ }
2295
+ /**
2296
+ * Uploads one file and answers what the message attaches (measured 2026-09-24). An upload is never
2297
+ * retried: a failure here happens before `MSG_SEND`, so nothing was sent.
2298
+ */
2299
+ async #upload({ path, bytes, kind, voice }) {
2300
+ const request = { count: 1, type: 0, uploaderType: 0, profile: false };
2301
+ if (kind === "photo") {
2302
+ const { url } = await this.#wire.uploads.photo(request);
2303
+ if (typeof url !== "string")
2304
+ throw new CliError("provider_error", "MAX gave no address to upload the photo to");
2305
+ return { _type: "PHOTO", photoToken: await uploadPhoto(url, path, bytes) };
2306
+ }
2307
+ if (kind === "video" || kind === "voice") {
2308
+ const slot = voice ? { ...request, type: 2, uploaderType: 1 } : request;
2309
+ const info = record(asArray((await this.#wire.uploads.video(slot)).info)[0]) ?? {};
2310
+ if (typeof info.url !== "string" || info.videoId === undefined) {
2311
+ throw new CliError("provider_error", `MAX gave no address to upload the ${kind} to`);
2312
+ }
2313
+ await uploadMedia(voice ? "voice message" : "video", info.url, path, bytes);
2314
+ return voice
2315
+ ? { _type: "AUDIO", audioId: info.videoId, duration: voice.durationMs, wave: voice.wave, token: info.token }
2316
+ : { _type: "VIDEO", videoId: info.videoId, token: info.token, videoType: 0 };
2317
+ }
2318
+ const info = record(asArray((await this.#wire.uploads.file(request)).info)[0]) ?? {};
2319
+ if (typeof info.url !== "string" || info.fileId === undefined) {
2320
+ throw new CliError("provider_error", "MAX gave no address to upload the file to");
2321
+ }
2322
+ await uploadFile(info.url, path, bytes);
2323
+ return { _type: "FILE", fileId: info.fileId };
2324
+ }
2325
+ /**
2326
+ * **A file is processed after its upload**, and a send before that is refused
2327
+ * `attachment.not.ready` (measured 2026-09-24). A refusal means nothing was sent, so asking again
2328
+ * with the same request — the same `cid` — is safe. Once a second, for up to thirty.
2329
+ */
2330
+ async #untilAttachmentsReady(send) {
2331
+ for (let attempt = 1;; attempt += 1) {
2332
+ try {
2333
+ return await send();
2334
+ }
2335
+ catch (error) {
2336
+ if (attempt >= 30 || !asCliError(error).message.includes("attachment.not.ready"))
2337
+ throw error;
2338
+ await this.#sleep(1000, undefined, "retry");
2339
+ }
2340
+ }
2341
+ }
2342
+ /**
2343
+ * Every change to a chat, guarded and journalled as a send is. Never retried: none of these is
2344
+ * measured to be safe to repeat, and a repeated `create` is a second group.
2345
+ */
2346
+ async #changeChat(chatId, action, act, personIds) {
2347
+ if (this.#offline)
2348
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change a chat");
2349
+ try {
2350
+ await this.#checked({ chatId, kind: "chat", action, ...(personIds ? { personIds } : {}) });
2351
+ }
2352
+ catch (error) {
2353
+ this.#sends?.record({ chatId, kind: "chat", action, outcome: "refused", errorCode: asCliError(error).code });
2354
+ throw error;
2355
+ }
2356
+ try {
2357
+ await this.#connectOnce();
2358
+ const done = await act();
2359
+ this.#sends?.record({
2360
+ chatId: done.chatId,
2361
+ kind: "chat",
2362
+ action,
2363
+ outcome: "sent",
2364
+ ...(done.people === undefined ? {} : { people: done.people }),
2365
+ });
2366
+ return done.result;
2367
+ }
2368
+ catch (error) {
2369
+ const failure = asCliError(error);
2370
+ this.#sends?.record({
2371
+ chatId,
2372
+ kind: "chat",
2373
+ action,
2374
+ outcome: failure.code === "outcome_unknown" ? "outcome_unknown" : "failed",
2375
+ errorCode: failure.code,
2376
+ });
2377
+ throw error;
2378
+ }
2379
+ }
2380
+ async #updateMembers(reference, people, action, change) {
2381
+ const chatId = await this.chats.resolve(reference);
2382
+ const userIds = await this.#personIds(people);
2383
+ return this.#changeChat(chatId, action, async () => {
2384
+ const answer = await this.#wire.chats.updateMembers({ chatId, userIds, ...change });
2385
+ const chat = record(answer.chat);
2386
+ return {
2387
+ chatId,
2388
+ people: userIds.length,
2389
+ result: { chatId, action, people: userIds, chat: chat ? toGroupCard(chat) : null },
2390
+ };
2391
+ }, action === "members.add" ? userIds : undefined);
2392
+ }
2393
+ takeApplied(operationId) {
2394
+ return operationId !== undefined && this.#appliedOperations.delete(operationId);
2395
+ }
2396
+ /** An id goes as given; a name is looked up in the store, and an ambiguous one is refused. */
2397
+ people(references) {
2398
+ return this.#personIds(references);
2399
+ }
2400
+ async #personIds(references) {
2401
+ if (references.every(isId))
2402
+ return references.map((reference) => reference.trim());
2403
+ if (!this.#record) {
2404
+ throw new CliError("configuration_error", `there is no local store for profile "${this.#store.profile}" to look names up in — give people by id`);
2405
+ }
2406
+ await this.#connectOnce();
2407
+ await this.#peopleFor(asArray(this.#session().chats));
2408
+ return Promise.all(references.map(async (reference) => (isId(reference) ? reference.trim() : (await this.#person(reference)).id)));
2409
+ }
2410
+ /** The `max serve` this client talks through, when it does — the server then journals each write. */
2411
+ get server() {
2412
+ return "journals" in this.#connection ? this.#connection : undefined;
2413
+ }
2414
+ /** A send id as MAX's own client makes one: the moment, in milliseconds, never repeated. */
2415
+ newSendId() {
2416
+ return String(this.#nextCid());
2417
+ }
2418
+ /**
2419
+ * A client id that never repeats within a process.
2420
+ *
2421
+ * `Date.now()` alone is not enough: two sends in the same millisecond get the same number, and if
2422
+ * MAX really does deduplicate by `cid` the second message vanishes with no error anywhere. Caught
2423
+ * by a test, not by a lost message. Across processes this is still millisecond-grained, which is
2424
+ * safe while one invocation sends one message.
2425
+ */
2426
+ #nextCid() {
2427
+ const now = Date.now();
2428
+ this.#previousCid = now > this.#previousCid ? now : this.#previousCid + 1;
2429
+ return this.#previousCid;
2430
+ }
2431
+ /**
2432
+ * Names for everyone in these chats, from the login response first and one request for the rest.
2433
+ *
2434
+ * One `CONTACT_INFO` for all the unknown ids rather than one per chat: a person with forty
2435
+ * dialogs should not cost forty round trips, and the login only carries a handful of contacts —
2436
+ * six of seventeen dialog partners, measured on a real account.
2437
+ */
2438
+ async #peopleFor(chats) {
2439
+ if (this.#people)
2440
+ return this.#people;
2441
+ const people = new Map();
2442
+ for (const raw of asArray(this.#session().contacts)) {
2443
+ const contact = toContact(raw);
2444
+ if (contact.id)
2445
+ people.set(contact.id, contact);
2446
+ }
2447
+ const viewerId = this.#store.readState().viewerId;
2448
+ const missing = new Set();
2449
+ for (const chat of chats) {
2450
+ if (toChat(chat).kind === "channel")
2451
+ continue;
2452
+ for (const id of this.#participantsOf(chat, viewerId) ?? [])
2453
+ if (!people.has(id))
2454
+ missing.add(id);
2455
+ }
2456
+ const named = [];
2457
+ for (const batch of batched([...missing], CONTACT_INFO_BATCH)) {
2458
+ const answer = await this.#wire.contacts.info({ contactIds: batch });
2459
+ for (const raw of asArray(answer.contacts)) {
2460
+ const contact = toContact(raw);
2461
+ if (!contact.id)
2462
+ continue;
2463
+ people.set(contact.id, contact);
2464
+ named.push(contact);
2465
+ }
2466
+ }
2467
+ // One-shot clients keep these names for the next command.
2468
+ if (named.length > 0) {
2469
+ await this.#keep("names", (record) => record.remember(named));
2470
+ }
2471
+ this.#people = people;
2472
+ return people;
2473
+ }
2474
+ /** The other party in a one-to-one chat, if there is exactly one. */
2475
+ #partnerOf(chat) {
2476
+ const participants = record(chat.participants);
2477
+ if (!participants)
2478
+ return undefined;
2479
+ const viewerId = this.#store.readState().viewerId;
2480
+ const others = Object.keys(participants).filter((id) => id !== viewerId);
2481
+ return others.length === 1 ? others[0] : undefined;
2482
+ }
2483
+ /**
2484
+ * A change to the account itself, not to a chat: a read-only profile refuses it, no recipient
2485
+ * list applies, it does not count towards the hourly limit, and the journal records which kind
2486
+ * of change it was. **Never retried** — at worst the command is typed again.
2487
+ */
2488
+ async #change(action, body) {
2489
+ if (this.#offline)
2490
+ throw new CliError("validation_error", "`--offline` reads what was recorded; it cannot change the account");
2491
+ try {
2492
+ await this.#checked({ chatId: null, kind: "account", action });
2493
+ }
2494
+ catch (error) {
2495
+ this.#sends?.record({
2496
+ chatId: null,
2497
+ kind: "account",
2498
+ action,
2499
+ outcome: "refused",
2500
+ errorCode: asCliError(error).code,
2501
+ });
2502
+ throw error;
2503
+ }
2504
+ let recorded = false;
2505
+ const done = () => {
2506
+ if (recorded)
2507
+ return;
2508
+ recorded = true;
2509
+ const operationId = currentOperation();
2510
+ if (operationId !== undefined)
2511
+ this.#appliedOperations.add(operationId);
2512
+ this.#sends?.record({ chatId: null, kind: "account", action, outcome: "sent" });
2513
+ };
2514
+ try {
2515
+ await this.#connectOnce();
2516
+ const result = await body(done);
2517
+ done();
2518
+ return result;
2519
+ }
2520
+ catch (error) {
2521
+ if (!recorded) {
2522
+ this.#sends?.record({
2523
+ chatId: null,
2524
+ kind: "account",
2525
+ action,
2526
+ outcome: "failed",
2527
+ errorCode: asCliError(error).code,
2528
+ });
2529
+ }
2530
+ throw error;
2531
+ }
2532
+ }
2533
+ #contactAction(action, wire, reference) {
2534
+ return this.#change(action, async () => {
2535
+ const known = isId(reference) ? (await this.#record?.people())?.get(reference.trim()) : undefined;
2536
+ const id = isId(reference) ? reference.trim() : (await this.#person(reference)).id;
2537
+ const answer = await this.#wire.contacts.update({ contactId: id, ...wire });
2538
+ const contact = record(answer.contact);
2539
+ if (contact)
2540
+ return this.#remember(toContact(contact));
2541
+ return known ?? { id, name: null, username: null, description: null, lastMessagedAt: null };
2542
+ });
2543
+ }
2544
+ async #person(reference) {
2545
+ if (this.#record)
2546
+ return pickStoredPerson(reference, await this.#record.people());
2547
+ throw new CliError("validation_error", "without a local store a person is named by id — `max contacts lookup` finds one");
2548
+ }
2549
+ async #remember(contact) {
2550
+ if (contact.id !== "") {
2551
+ await this.#keep("contact", (record) => record.remember([contact]));
2552
+ }
2553
+ return contact;
2554
+ }
2555
+ async #folders() {
2556
+ if (this.#offline)
2557
+ throw new CliError("validation_error", "`--offline` reads what was recorded, and folders are not recorded");
2558
+ await this.#connectOnce();
2559
+ const answer = await this.#wire.folders.list({ folderSync: 0 });
2560
+ const folders = asArray(answer.folders);
2561
+ const order = Array.isArray(answer.foldersOrder) ? answer.foldersOrder.map(String) : [];
2562
+ const rank = (folder) => {
2563
+ const at = order.indexOf(String(folder.id));
2564
+ return at === -1 ? order.length : at;
2565
+ };
2566
+ return [...folders].sort((a, b) => rank(a) - rank(b));
2567
+ }
2568
+ /**
2569
+ * What a settings change sent, and what MAX answered, folded into the login held — as web.max.ru folds
2570
+ * the answer into its config. A `max serve` hands that login to the next command, so without this the
2571
+ * next read shows the old setting (measured 2026-10-08), and the next resume would send a stale hash.
2572
+ */
2573
+ #keepSettings(sent, answer) {
2574
+ const login = this.#session();
2575
+ const config = record(login.config) ?? {};
2576
+ login.config = {
2577
+ ...config,
2578
+ ...(typeof answer.hash === "string" ? { hash: answer.hash } : {}),
2579
+ ...(sent.user ? { user: { ...(record(config.user) ?? {}), ...sent.user, ...(record(answer.user) ?? {}) } } : {}),
2580
+ ...(sent.chats ? { chats: { ...(record(config.chats) ?? {}), ...sent.chats } } : {}),
2581
+ };
2582
+ }
2583
+ /** What the web client sends as the chat's `syncTime`: up to where this account has the chat. */
2584
+ #lastEventOf(chatId) {
2585
+ const chat = asArray(this.#session().chats).find((one) => asId(one.id) === chatId);
2586
+ return eventTime(chat) || Date.now();
2587
+ }
2588
+ #session() {
2589
+ if (!this.#login)
2590
+ throw new CliError("configuration_error", "connect() was never called");
2591
+ return this.#login;
2592
+ }
2593
+ }
2594
+ /**
2595
+ * PyMax `AdminPermission`; the sum is what MAX takes. `read` and `link` measured 2026-09-27 by
2596
+ * switching them in the app for a bot and reading its `permissions` back (`MAX-61`): «Read messages»
2597
+ * sets 1 and 32 together — the Bot API calls them `read_all_messages` and `write` — and «Update chat
2598
+ * link» 128. A bot without `read` sees no message of the group.
2599
+ */
2600
+ export const ADMIN_RIGHTS = {
2601
+ read: 1 | 32,
2602
+ members: 2,
2603
+ admins: 4,
2604
+ info: 8,
2605
+ pin: 16,
2606
+ link: 128,
2607
+ post: 256,
2608
+ edit: 512,
2609
+ delete: 1024,
2610
+ };
2611
+ /** The most chats `max serve` holds from pushes before it logs in again instead. */
2612
+ const LIVE_CHATS = 10_000;
2613
+ /** PyMax's page, the one measured. */
2614
+ const MEMBERS_PAGE = 50;
2615
+ const MEMBERS_READ = 5000;
2616
+ /**
2617
+ * A private link goes as `join/<token>`, whatever came before it, as PyMax sends it and as
2618
+ * measured. A public one (`https://max.ru/<name>`) goes whole, which is PyMax's claim only.
2619
+ */
2620
+ export const wireLink = (link) => {
2621
+ const trimmed = link.trim();
2622
+ const at = trimmed.indexOf("join/");
2623
+ if (at >= 0 && trimmed.length > at + "join/".length)
2624
+ return trimmed.slice(at);
2625
+ if (/^(https:\/\/)?max\.ru\/[\w.-]+\/?$/.test(trimmed))
2626
+ return trimmed;
2627
+ throw new CliError("validation_error", "not a MAX link — expected https://max.ru/join/… or https://max.ru/<name>");
2628
+ };
2629
+ const largestMp4 = (answer) => Object.entries(answer)
2630
+ .map(([key, value]) => ({ height: /^MP4_(\d+)$/.exec(key)?.[1], value }))
2631
+ .filter((entry) => entry.height !== undefined && typeof entry.value === "string")
2632
+ .sort((a, b) => Number(b.height) - Number(a.height))[0]?.value;
2633
+ /**
2634
+ * **The shortest search the index can answer is three characters**, and below that it returns
2635
+ * nothing rather than complaining (measured 2026-09-22). An empty list reads as "no matches",
2636
+ * so this refuses instead — the one answer that is never mistaken for a result.
2637
+ *
2638
+ * It is checked here rather than in each command, and on every path rather than only where a
2639
+ * store exists: a filter that works with a local store and not without it would make the answer depend
2640
+ * on something the person never asked about.
2641
+ */
2642
+ const MIN_QUERY = 3;
2643
+ const checkedQuery = (query) => {
2644
+ if (query === undefined)
2645
+ return undefined;
2646
+ const trimmed = query.trim();
2647
+ if (trimmed.length < MIN_QUERY) {
2648
+ throw new CliError("validation_error", `a search needs at least ${MIN_QUERY} characters — "${trimmed}" is ${trimmed.length}`);
2649
+ }
2650
+ return trimmed;
2651
+ };
2652
+ /**
2653
+ * **One `CONTACT_INFO` for a hundred people.** The bound is unmeasured; the comparable one is
2654
+ * `chatsCount`, where 100 is accepted and 200 comes back out of range (`PROTO-3`). Somebody in
2655
+ * forty groups is the case that will find the real number, and it will find it as a refusal that
2656
+ * names itself rather than as a wrong answer.
2657
+ */
2658
+ const CONTACT_INFO_BATCH = 100;
2659
+ /** The most chats one `CHATS_LIST` answer was seen to hold (26, 2026-09-25). A page that full may have been cut. */
2660
+ const CHATS_PAGE_SEEN = 26;
2661
+ /**
2662
+ * When a message was sent, read from its id: **`id >> 16` is the send time in milliseconds**, the
2663
+ * low 16 bits a counter. Measured 2026-09-22 on three messages across two days, exact every time.
2664
+ * `undefined` for anything that is not a message id.
2665
+ */
2666
+ export const timeOfMessageId = (id) => {
2667
+ if (!/^\d{10,20}$/.test(id))
2668
+ return undefined;
2669
+ const time = Number(BigInt(id) >> 16n);
2670
+ return Number.isSafeInteger(time) && time > 0 ? time : undefined;
2671
+ };
2672
+ const HISTORY_PAGE = 100;
2673
+ const EVENTS_READ = 2000;
2674
+ /** How far back `chats events` looks without `since`. */
2675
+ export const EVENTS_DAYS = 7;
2676
+ const ALL_CHATS_FOLDER = "all.chat.folder";
2677
+ export const FIRST_TAB_SYNC = { folders: 0, calls: 0, assets: {} };
2678
+ const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
2679
+ const eventTime = (chat) => {
2680
+ const time = record(chat)?.lastEventTime;
2681
+ return typeof time === "number" ? time : 0;
2682
+ };
2683
+ /** Reactions on a message changed; PyMax calls it `NOTIF_MSG_REACTIONS_CHANGED` (tab recording 2026-09-25). */
2684
+ const REACTIONS_CHANGED = 155;
2685
+ /** MAX pushes this when a message arrives in any chat. PyMax calls it `NOTIF_MESSAGE`. */
2686
+ const NEW_MESSAGE = 128;
2687
+ /** Bounded so that deleting a long history is many spread-out calls, never one sweep (`MAX-47`). */
2688
+ export const DELETE_AT_ONCE = 10;
2689
+ /** Read up to a point — by us on another device, or by somebody else. */
2690
+ const READ_MARK = 130;
2691
+ /** A chat changed; MAX sends it whole. */
2692
+ const CHAT_CHANGED = 135;
2693
+ const CONFIG_CHANGED = 134;
2694
+ const CHAT_FACTS = ["title", "description", "participantsCount", "status", "type", "baseIconUrl", "owner"];
2695
+ /** Messages deleted (140 in PyMax, 142 in the web client): the snapshot cannot follow them. */
2696
+ const CHANGES_CHATS = new Set([140, 142]);
2697
+ /** A join that only asked: MAX stamps the request and leaves the owner out of the participants. */
2698
+ export const isJoinRequest = (chat) => chat.joinRequestTime !== undefined && chat.joinRequestTime !== null;
2699
+ const keyboardOf = (raw) => asArray(raw.attaches).find((attach) => attach._type === "INLINE_KEYBOARD");
2700
+ const isPresent = (value) => value !== null && value !== undefined;
2701
+ const needsName = (message) => message.senderId !== null && message.senderName === null && message.outgoing !== true;
2702
+ const batched = (items, size) => {
2703
+ const batches = [];
2704
+ for (let at = 0; at < items.length; at += size)
2705
+ batches.push(items.slice(at, at + size));
2706
+ return batches;
2707
+ };
2708
+ /** Filtering still works when saving the local record failed. */
2709
+ const matching = (chats, { query, kind, unread }) => {
2710
+ const needle = query?.toLocaleLowerCase();
2711
+ return chats.filter((chat) => (kind === undefined || chat.kind === kind) &&
2712
+ (!unread || (chat.unreadCount ?? 0) > 0) &&
2713
+ (needle === undefined || (chat.title ?? "").toLocaleLowerCase().includes(needle)));
2714
+ };
2715
+ /** Paging over a list already in hand — the fallback for when there is no store to page in SQL. */
2716
+ const paged = (items, limit, offset) => {
2717
+ const page = limit === undefined ? items.slice(offset) : items.slice(offset, offset + limit);
2718
+ return { items: page, hasMore: offset + page.length < items.length };
2719
+ };
2720
+ /**
2721
+ * The marker only means anything if MAX gave us one. A login without a `time` is not a reason to
2722
+ * store `0` — that would tell the next login to send everything, which is exactly what a stored
2723
+ * marker exists to stop.
2724
+ */
2725
+ const asMarker = (time) => typeof time === "number" && Number.isFinite(time) && time > 0 ? time : undefined;
2726
+ /** The reason, never the payload: a store's failure is a code, and the rows are people's names. */
2727
+ const reasonOf = (error) => {
2728
+ const code = error?.code;
2729
+ return typeof code === "string" ? code : error instanceof Error ? error.name : "an unknown problem";
2730
+ };
2731
+ const viewer = (store) => {
2732
+ const viewerId = store.readState().viewerId;
2733
+ return viewerId === undefined ? {} : { viewerId };
2734
+ };
2735
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
2736
+ const asArray = (value) => Array.isArray(value) ? value.filter((item) => record(item) !== undefined) : [];
2737
+ /**
2738
+ * MAX's own words become our error codes.
2739
+ *
2740
+ * A refusal naming the token is an authentication problem the user can fix by logging in again;
2741
+ * everything else from the far end is `provider_error`, and a connection that died is a network
2742
+ * one. Nothing here retries: whether an operation may be repeated is the caller's question.
2743
+ */
2744
+ const asCliError = (error) => {
2745
+ if (error instanceof CliError)
2746
+ return error;
2747
+ if (error instanceof ProtocolError) {
2748
+ const key = providerErrorKey(record(error.payload)?.error);
2749
+ const refused = asRefusal(error);
2750
+ return key === undefined
2751
+ ? refused
2752
+ : new CliError(refused.code, refused.message, { ...refused.details, providerError: key });
2753
+ }
2754
+ const message = error instanceof Error ? error.message : String(error);
2755
+ if (message.includes("did not answer"))
2756
+ return new CliError("timeout", message);
2757
+ return new CliError("network_error", message);
2758
+ };
2759
+ const asRefusal = (error) => {
2760
+ const text = error.message.toLowerCase();
2761
+ // First: "too many auth attempts" is a limit, not a bad token. The words are claims —
2762
+ // `error.limit.violate` from PyMax #106, `rate_limit_exceeded` from GREEN-API — never measured here.
2763
+ if (LIMIT_WORDS.some((words) => text.includes(words))) {
2764
+ return new CliError("rate_limited", error.message, { operation: String(error.opcode) });
2765
+ }
2766
+ if (text.includes("token") || text.includes("auth")) {
2767
+ return new CliError("authentication_error", `${error.message} — the session may have expired; run \`max session start\``);
2768
+ }
2769
+ return new CliError("provider_error", error.message, { operation: String(error.opcode) });
2770
+ };
2771
+ /** A link that was reset, or never led anywhere, is `not.found` from MAX (measured 2026-09-28). */
2772
+ const deadLink = (link) => (error) => {
2773
+ const failure = asCliError(error);
2774
+ if (failure.details.providerError !== "not.found")
2775
+ throw failure;
2776
+ throw new CliError("not_found", `${link.trim()} leads nowhere — the link was reset, or never worked`);
2777
+ };
2778
+ const LIMIT_WORDS = ["limit.violate", "rate_limit", "rate limit", "too many", "слишком много"];
2779
+ /** Before any request: a login inside the pause is one more attempt MAX counts (`MAX-38`). */
2780
+ export const refuseWhilePaused = (state) => {
2781
+ const until = loginPausedUntil(state);
2782
+ if (until === undefined)
2783
+ return;
2784
+ throw new CliError("rate_limited", `MAX refused this profile's last login for too many attempts; it will not try again before ${until} — ` +
2785
+ "logging in sooner is what keeps an account locked");
2786
+ };
2787
+ const ownNames = (profile) => {
2788
+ const contact = record(profile.contact) ?? profile;
2789
+ const names = asArray(contact.names);
2790
+ const own = names.find((entry) => entry.type === "ONEME") ?? names[0];
2791
+ return {
2792
+ ...(typeof own?.firstName === "string" && own.firstName !== "" ? { firstName: own.firstName } : {}),
2793
+ ...(typeof own?.lastName === "string" ? { lastName: own.lastName } : {}),
2794
+ };
2795
+ };
2796
+ /** `+` and digits, as the lookup was measured. ⚠ The refusal never repeats what was typed. */
2797
+ export const wirePhone = (typed) => {
2798
+ const compact = typed.replace(/[\s()-]/g, "");
2799
+ const digits = compact.replace(/^\+/, "");
2800
+ if (!/^\d{7,15}$/.test(digits)) {
2801
+ throw new CliError("validation_error", "a phone number is 7 to 15 digits, with an optional + and the country code");
2802
+ }
2803
+ // A Russian number written the domestic way would otherwise go out as +8…, somebody else's.
2804
+ if (!compact.startsWith("+") && /^8\d{10}$/.test(digits)) {
2805
+ throw new CliError("validation_error", "write a number that starts with 8 with its country code instead: +7…");
2806
+ }
2807
+ return `+${digits}`;
2808
+ };
2809
+ /** Measured 2026-09-25 (`MAX-57`, ASCII): 20 characters taken, 21 refused with `folder.validation.title.too-long`. */
2810
+ const FOLDER_TITLE_MAX = 20;
2811
+ const folderTitle = (title) => {
2812
+ const trimmed = title.trim();
2813
+ if (trimmed === "")
2814
+ throw new CliError("validation_error", "a folder needs a title");
2815
+ if ([...trimmed].length > FOLDER_TITLE_MAX) {
2816
+ throw new CliError("validation_error", `a folder title is at most ${FOLDER_TITLE_MAX} characters in MAX`);
2817
+ }
2818
+ return trimmed;
2819
+ };
2820
+ const pickFolder = (reference, folders) => {
2821
+ const wanted = reference.trim();
2822
+ const byId = folders.find((folder) => folder.id === wanted);
2823
+ if (byId)
2824
+ return byId;
2825
+ const named = folders.filter((folder) => typeof folder.title === "string" && folder.title === wanted);
2826
+ if (named.length === 1 && named[0])
2827
+ return named[0];
2828
+ if (named.length > 1) {
2829
+ throw new CliError("validation_error", `${named.length} folders are called "${wanted}" — name one by id (\`max chats folders list\`)`);
2830
+ }
2831
+ throw new CliError("not_found", `no folder "${wanted}" — \`max chats folders list\` shows them`);
2832
+ };
2833
+ /** A list of ids MAX sends as plain numbers; `asArray` keeps only objects. */
2834
+ const numbers = (value) => (Array.isArray(value) ? value.map(Number).filter(Number.isFinite) : []);
2835
+ const MEDIA_TYPES = { photo: "PHOTO", video: "VIDEO", file: "FILE", audio: "AUDIO", link: "SHARE" };
2836
+ //# sourceMappingURL=client.js.map