@aspalagin/openclaw-max 0.7.2

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/SKILL.md +108 -0
  4. package/dist/index.js +17 -0
  5. package/dist/setup-entry.js +8 -0
  6. package/dist/src/accounts.js +148 -0
  7. package/dist/src/actions.js +421 -0
  8. package/dist/src/api.js +553 -0
  9. package/dist/src/callbacks.js +111 -0
  10. package/dist/src/channel-agent-prompt.js +38 -0
  11. package/dist/src/channel-config.js +103 -0
  12. package/dist/src/channel-directory.js +84 -0
  13. package/dist/src/channel-lifecycle.js +219 -0
  14. package/dist/src/channel-outbound.js +133 -0
  15. package/dist/src/channel-policy.js +130 -0
  16. package/dist/src/channel.js +76 -0
  17. package/dist/src/config-schema.js +133 -0
  18. package/dist/src/deliver.js +126 -0
  19. package/dist/src/dispatch.js +186 -0
  20. package/dist/src/format.js +31 -0
  21. package/dist/src/inbound-attachments.js +125 -0
  22. package/dist/src/inbound.js +362 -0
  23. package/dist/src/media-temp.js +92 -0
  24. package/dist/src/model-buttons.js +96 -0
  25. package/dist/src/monitor-types.js +27 -0
  26. package/dist/src/monitor.js +40 -0
  27. package/dist/src/onboarding.js +180 -0
  28. package/dist/src/polling.js +98 -0
  29. package/dist/src/presentation.js +374 -0
  30. package/dist/src/runtime.js +36 -0
  31. package/dist/src/russian-trusted-ca.js +84 -0
  32. package/dist/src/send.js +612 -0
  33. package/dist/src/state.js +111 -0
  34. package/dist/src/sticker-cache.js +26 -0
  35. package/dist/src/stream-draft.js +102 -0
  36. package/dist/src/types.js +5 -0
  37. package/dist/src/webhook-queue.js +215 -0
  38. package/dist/src/webhook-runner.js +233 -0
  39. package/dist/src/webhook.js +196 -0
  40. package/openclaw.plugin.json +263 -0
  41. package/package.json +100 -0
  42. package/scripts/check-cycles.mjs +38 -0
  43. package/scripts/test-api.mjs +169 -0
  44. package/scripts/test-send.mjs +162 -0
  45. package/scripts/update-schema.mjs +48 -0
  46. package/src/accounts.ts +193 -0
  47. package/src/actions.ts +501 -0
  48. package/src/api.ts +758 -0
  49. package/src/callbacks.ts +140 -0
  50. package/src/channel-agent-prompt.ts +47 -0
  51. package/src/channel-config.ts +138 -0
  52. package/src/channel-directory.ts +90 -0
  53. package/src/channel-lifecycle.ts +256 -0
  54. package/src/channel-outbound.ts +159 -0
  55. package/src/channel-policy.ts +185 -0
  56. package/src/channel.ts +99 -0
  57. package/src/config-schema.ts +154 -0
  58. package/src/deliver.ts +168 -0
  59. package/src/dispatch.ts +224 -0
  60. package/src/format.ts +33 -0
  61. package/src/inbound-attachments.ts +152 -0
  62. package/src/inbound.ts +412 -0
  63. package/src/media-temp.ts +103 -0
  64. package/src/model-buttons.ts +150 -0
  65. package/src/monitor-types.ts +61 -0
  66. package/src/monitor.ts +49 -0
  67. package/src/onboarding.ts +215 -0
  68. package/src/polling.ts +125 -0
  69. package/src/presentation.ts +452 -0
  70. package/src/runtime.ts +56 -0
  71. package/src/russian-trusted-ca.ts +86 -0
  72. package/src/send.ts +774 -0
  73. package/src/state.ts +149 -0
  74. package/src/sticker-cache.ts +28 -0
  75. package/src/stream-draft.ts +125 -0
  76. package/src/types.ts +263 -0
  77. package/src/webhook-queue.ts +248 -0
  78. package/src/webhook-runner.ts +296 -0
  79. package/src/webhook.ts +268 -0
package/src/api.ts ADDED
@@ -0,0 +1,758 @@
1
+ /**
2
+ * MAX Bot API client — thin wrapper around platform-api2.max.ru
3
+ *
4
+ * API docs: https://dev.max.ru/docs-api
5
+ * Auth: Authorization header with bot token
6
+ * Rate limit: 30 rps
7
+ *
8
+ * TLS: platform-api2.max.ru serves a certificate issued by the Russian Trusted
9
+ * (Минцифры) CA, which is not in the default Node trust store. Requests go
10
+ * through a dedicated undici dispatcher whose CA set = system roots + Russian
11
+ * Trusted Root/Sub CA. Trust is scoped to this client only, never process-wide.
12
+ */
13
+
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+ import * as tls from 'node:tls';
16
+
17
+ import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
18
+ import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
19
+
20
+ import { RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA } from './russian-trusted-ca.js';
21
+ import type {
22
+ MaxBotCommand,
23
+ MaxBotCommandsInfo,
24
+ MaxChat,
25
+ MaxChatMember,
26
+ MaxMessage,
27
+ MaxNewMessageBody,
28
+ MaxSenderAction,
29
+ MaxSendResult,
30
+ MaxSimpleResult,
31
+ MaxSubscriptionsResponse,
32
+ MaxUpdatesResponse,
33
+ MaxUpdateType,
34
+ MaxUploadUrlResponse,
35
+ MaxUser,
36
+ MaxVideoInfo,
37
+ } from './types.js';
38
+
39
+ export type {
40
+ MaxAttachment,
41
+ MaxBotCommand,
42
+ MaxBotCommandsInfo,
43
+ MaxCallback,
44
+ MaxChat,
45
+ MaxChatMember,
46
+ MaxInlineKeyboardAttachment,
47
+ MaxInlineKeyboardButton,
48
+ MaxLinkedMessage,
49
+ MaxMessage,
50
+ MaxMessageBody,
51
+ MaxNewMessageBody,
52
+ MaxRecipient,
53
+ MaxSenderAction,
54
+ MaxSendResult,
55
+ MaxSimpleResult,
56
+ MaxStickerAttachment,
57
+ MaxSubscription,
58
+ MaxSubscriptionsResponse,
59
+ MaxUpdate,
60
+ MaxUpdatesResponse,
61
+ MaxUpdateType,
62
+ MaxUploadResult,
63
+ MaxUploadUrlResponse,
64
+ MaxUser,
65
+ MaxVideoInfo,
66
+ } from './types.js';
67
+
68
+ /**
69
+ * platform-api.max.ru is shut down on 2026-07-19; platform-api2.max.ru is the
70
+ * canonical endpoint since June 2026.
71
+ */
72
+ const BASE_URL = 'https://platform-api2.max.ru';
73
+
74
+ /**
75
+ * Default per-request deadline. Raised from 10s after an intermittent in-process
76
+ * stall aborted sends at exactly 10s ("This operation was aborted") while the
77
+ * same request from a fresh process completed in <300ms. The long poll sets its
78
+ * own (longer) timeout, so this only bounds one-shot calls like send/edit/get.
79
+ */
80
+ const DEFAULT_TIMEOUT_MS = 30_000;
81
+
82
+ /** Round trips slower than this are logged, to fingerprint a stalling path. */
83
+ const MAX_SLOW_REQUEST_MS = 5_000;
84
+
85
+ /** New connections whose DNS+TCP+TLS exceeds this are logged. */
86
+ const MAX_SLOW_CONNECT_MS = 2_000;
87
+
88
+ // ────────────────────── HTTP transport ──────────────────────
89
+
90
+ type FetchLike = (
91
+ url: string,
92
+ init?: Record<string, unknown>,
93
+ ) => Promise<{
94
+ ok: boolean;
95
+ status: number;
96
+ headers?: { get(name: string): string | null };
97
+ json(): Promise<unknown>;
98
+ text(): Promise<string>;
99
+ }>;
100
+
101
+ let testFetchOverride: FetchLike | undefined;
102
+
103
+ /**
104
+ * Test seam: route all MAX HTTP through the given fetch (e.g. a vi.fn or a
105
+ * passthrough to a mocked globalThis.fetch). Pass undefined to restore.
106
+ */
107
+ export function setMaxFetchForTests(fetchImpl: FetchLike | undefined): void {
108
+ testFetchOverride = fetchImpl;
109
+ }
110
+
111
+ let cachedDispatcher: Agent | undefined;
112
+
113
+ function getMaxDispatcher(): Agent {
114
+ if (!cachedDispatcher) {
115
+ const tlsWithCaList = tls as unknown as {
116
+ getCACertificates?: (type: string) => readonly string[];
117
+ };
118
+ // getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
119
+ const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
120
+ const ca = [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
121
+ // Wrap the TLS connector to time the DNS+TCP+TLS phase, scoped to this
122
+ // dispatcher only (never process-wide). Diagnosing the intermittent
123
+ // "This operation was aborted" needs to separate a connect stall from a
124
+ // response-wait stall; a slow or failed connect leaves a fingerprint here.
125
+ const connect = buildConnector({ ca });
126
+ type Connect = typeof connect;
127
+ const timedConnect: Connect = (options, callback) => {
128
+ const startedAt = Date.now();
129
+ connect(options, (...args: Parameters<Parameters<Connect>[1]>) => {
130
+ const err = args[0];
131
+ const elapsedMs = Date.now() - startedAt;
132
+ if (err || elapsedMs > MAX_SLOW_CONNECT_MS) {
133
+ console.error(
134
+ `[MAX API] connect ${options.hostname} ${err ? 'failed' : 'slow'} dns+tcp+tls=${elapsedMs}ms` +
135
+ (err ? ` (${err.name})` : ''),
136
+ );
137
+ }
138
+ callback(...args);
139
+ });
140
+ };
141
+ cachedDispatcher = new Agent({ connect: timedConnect });
142
+ }
143
+ return cachedDispatcher;
144
+ }
145
+
146
+ function maxFetch(url: string, init: Record<string, unknown>): ReturnType<FetchLike> {
147
+ if (testFetchOverride) return testFetchOverride(url, init);
148
+ return undiciFetch(url, {
149
+ ...init,
150
+ dispatcher: getMaxDispatcher(),
151
+ } as Parameters<typeof undiciFetch>[1]) as unknown as ReturnType<FetchLike>;
152
+ }
153
+
154
+ function escapeMultipartHeaderValue(value: string): string {
155
+ return value
156
+ .replace(/\\/g, '\\\\')
157
+ .replace(/"/g, '\\"')
158
+ .replace(/[\r\n]/g, '_');
159
+ }
160
+
161
+ function buildMultipartFileBody(
162
+ fieldName: string,
163
+ fileName: string,
164
+ mimeType: string,
165
+ fileBuffer: Buffer,
166
+ ): { body: Buffer; contentType: string } {
167
+ const boundary = `----openclaw-max-${randomBytes(12).toString('hex')}`;
168
+ const escapedField = escapeMultipartHeaderValue(fieldName);
169
+ const escapedFile = escapeMultipartHeaderValue(fileName);
170
+ const preamble = Buffer.from(
171
+ `--${boundary}\r\n` +
172
+ `Content-Disposition: form-data; name="${escapedField}"; filename="${escapedFile}"\r\n` +
173
+ `Content-Type: ${mimeType}\r\n\r\n`,
174
+ );
175
+ const closing = Buffer.from(`\r\n--${boundary}--\r\n`);
176
+
177
+ return {
178
+ body: Buffer.concat([preamble, fileBuffer, closing]),
179
+ contentType: `multipart/form-data; boundary=${boundary}`,
180
+ };
181
+ }
182
+
183
+ // ────────────────────── API Client ──────────────────────
184
+
185
+ export interface MaxApiOptions {
186
+ token: string;
187
+ baseUrl?: string;
188
+ timeoutMs?: number;
189
+ /** Retry attempts for transient errors (429 / 502-504 / network). 0 disables. Default 3. */
190
+ retryAttempts?: number;
191
+ /** Per-chat send limiter for POST /messages; defaults to the process-wide one, null disables. */
192
+ sendLimiter?: MaxChatSendLimiter | null;
193
+ }
194
+
195
+ /** MAX docs (POST /messages): at most 2 messages per second into one dialog, chat or channel. */
196
+ export const MAX_SENDS_PER_CHAT_PER_SECOND = 2;
197
+
198
+ /**
199
+ * Sliding-window limiter for sends into one chat. Callers for the same key are
200
+ * served in order; a caller over the limit waits until the oldest send in the
201
+ * window is a full window old. Keys are independent, long polling and other
202
+ * requests never pass through it.
203
+ */
204
+ export class MaxChatSendLimiter {
205
+ private recent = new Map<string, number[]>();
206
+ private tails = new Map<string, Promise<void>>();
207
+
208
+ constructor(
209
+ private limit = MAX_SENDS_PER_CHAT_PER_SECOND,
210
+ private windowMs = 1000,
211
+ ) {}
212
+
213
+ acquire(key: string): Promise<void> {
214
+ const take = async (): Promise<void> => {
215
+ for (;;) {
216
+ const now = Date.now();
217
+ const times = (this.recent.get(key) ?? []).filter((t) => now - t < this.windowMs);
218
+ if (times.length < this.limit) {
219
+ times.push(now);
220
+ this.recent.set(key, times);
221
+ return;
222
+ }
223
+ this.recent.set(key, times);
224
+ await new Promise((resolve) => setTimeout(resolve, times[0] + this.windowMs - now));
225
+ }
226
+ };
227
+ const slot = (this.tails.get(key) ?? Promise.resolve()).then(take);
228
+ this.tails.set(key, slot);
229
+ void slot.finally(() => {
230
+ if (this.tails.get(key) === slot) this.tails.delete(key);
231
+ });
232
+ if (this.recent.size > 1000) this.prune();
233
+ return slot;
234
+ }
235
+
236
+ private prune(): void {
237
+ const now = Date.now();
238
+ for (const [key, times] of this.recent) {
239
+ if (!this.tails.has(key) && times.every((t) => now - t >= this.windowMs))
240
+ this.recent.delete(key);
241
+ }
242
+ }
243
+ }
244
+
245
+ let sharedSendLimiter = new MaxChatSendLimiter();
246
+
247
+ /** @internal Fresh process-wide send limiter (test isolation). */
248
+ export function resetMaxSendLimiterForTests(): void {
249
+ sharedSendLimiter = new MaxChatSendLimiter();
250
+ }
251
+
252
+ export class MaxApiError extends Error {
253
+ /** MAX error code from the response body, e.g. "attachment.not.ready" */
254
+ public code?: string;
255
+ /** Parsed Retry-After (ms), when the response carried one */
256
+ public retryAfterMs?: number;
257
+
258
+ constructor(
259
+ message: string,
260
+ public status: number,
261
+ public body?: unknown,
262
+ ) {
263
+ super(message);
264
+ this.name = 'MaxApiError';
265
+ if (body && typeof body === 'object') {
266
+ const code = (body as Record<string, unknown>).code;
267
+ if (typeof code === 'string') this.code = code;
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * A request that hit the client-side deadline (our AbortController), as opposed
274
+ * to a caller-supplied stop signal. Carries the stalled phase so the send path
275
+ * can decide whether one retry on a fresh connection is safe.
276
+ */
277
+ export class MaxRequestTimeoutError extends Error {
278
+ constructor(
279
+ public method: string,
280
+ public path: string,
281
+ public elapsedMs: number,
282
+ public phase: 'awaiting-response' | 'reading-body',
283
+ ) {
284
+ super(`MAX API ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`);
285
+ this.name = 'MaxRequestTimeoutError';
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Whether an error is safe to retry. `idempotent` gates the ambiguous cases:
291
+ * for non-idempotent calls (POST /messages, POST /answers) a 5xx or network
292
+ * failure may mean the request WAS applied server-side, so retrying would
293
+ * duplicate the message — only a definite 429 (never applied) is retried.
294
+ */
295
+ function isRetryableError(err: unknown, idempotent: boolean): boolean {
296
+ // A client-side timeout is safe to retry only for idempotent methods here; the
297
+ // send path handles its own single, duplicate-safe retry for POST /messages.
298
+ if (err instanceof MaxRequestTimeoutError) return idempotent;
299
+ if (err instanceof MaxApiError) {
300
+ if (err.status === 429) return true; // rate-limited: request was rejected, safe to retry
301
+ // 500 excluded everywhere: the request may have been applied.
302
+ if (err.status === 502 || err.status === 503 || err.status === 504) return idempotent;
303
+ return false;
304
+ }
305
+ if (err instanceof Error && err.name === 'AbortError') return false;
306
+ // undici network-level failure (fetch failed, ECONNRESET, socket hang up…):
307
+ // ambiguous — the request may have reached the server, so retry only if idempotent.
308
+ return idempotent && (err instanceof TypeError || err instanceof Error);
309
+ }
310
+
311
+ const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']);
312
+
313
+ export class MaxApi {
314
+ private token: string;
315
+ private baseUrl: string;
316
+ private timeoutMs: number;
317
+ private retryAttempts: number;
318
+ private sendLimiter: MaxChatSendLimiter | null;
319
+ /** Limiter key prefix: the same chat under two bots has two budgets. */
320
+ private tokenKey: string;
321
+
322
+ constructor(opts: MaxApiOptions) {
323
+ this.token = opts.token;
324
+ this.sendLimiter = opts.sendLimiter === undefined ? sharedSendLimiter : opts.sendLimiter;
325
+ this.tokenKey = createHash('sha256').update(opts.token).digest('hex').slice(0, 12);
326
+ this.baseUrl = opts.baseUrl ?? BASE_URL;
327
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
328
+ const envAttempts = Number(process.env.OPENCLAW_MAX_RETRY_ATTEMPTS);
329
+ this.retryAttempts =
330
+ opts.retryAttempts ?? (Number.isFinite(envAttempts) && envAttempts >= 0 ? envAttempts : 3);
331
+ }
332
+
333
+ // ── HTTP helpers ──
334
+
335
+ private async requestOnce<T>(
336
+ method: string,
337
+ path: string,
338
+ params?: Record<string, string | number | boolean | undefined | null>,
339
+ body?: unknown,
340
+ timeoutMs?: number,
341
+ externalSignal?: AbortSignal,
342
+ ): Promise<T> {
343
+ const url = new URL(path, this.baseUrl);
344
+ if (params) {
345
+ for (const [k, v] of Object.entries(params)) {
346
+ if (v != null) url.searchParams.set(k, String(v));
347
+ }
348
+ }
349
+
350
+ const controller = new AbortController();
351
+ let timedOutByUs = false;
352
+ let gotResponse = false;
353
+ const startedAt = Date.now();
354
+ const timeout = setTimeout(() => {
355
+ timedOutByUs = true;
356
+ controller.abort();
357
+ }, timeoutMs ?? this.timeoutMs);
358
+ // A caller-supplied signal (the channel stop) must cancel the in-flight
359
+ // request, not just be polled between requests: a 35s long poll otherwise
360
+ // keeps running long after the gateway asked the channel to stop.
361
+ const signal = externalSignal
362
+ ? AbortSignal.any([controller.signal, externalSignal])
363
+ : controller.signal;
364
+
365
+ try {
366
+ const res = await maxFetch(url.toString(), {
367
+ method,
368
+ headers: {
369
+ Authorization: this.token,
370
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
371
+ },
372
+ body: body ? JSON.stringify(body) : undefined,
373
+ signal,
374
+ });
375
+ gotResponse = true;
376
+
377
+ const json = (await res.json().catch(() => null)) as T;
378
+
379
+ const elapsedMs = Date.now() - startedAt;
380
+ // The long poll blocks server-side for its `timeout` seconds by design, so
381
+ // it is excluded here; every other call reaching the server slowly is a
382
+ // fingerprint that points away from a connect hang.
383
+ if (elapsedMs > MAX_SLOW_REQUEST_MS && path !== '/updates') {
384
+ console.error(`[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`);
385
+ }
386
+
387
+ if (!res.ok) {
388
+ // Never log the request body: it may carry the webhook secret (POST
389
+ // /subscriptions) or private message text. Log only method+path+status.
390
+ console.error(`[MAX API] ${method} ${path} → ${res.status}`);
391
+ const error = new MaxApiError(
392
+ `MAX API ${method} ${path} → ${res.status}`,
393
+ res.status,
394
+ json,
395
+ );
396
+ const retryAfter = res.headers?.get?.('retry-after');
397
+ if (retryAfter) {
398
+ const seconds = Number(retryAfter);
399
+ if (Number.isFinite(seconds) && seconds > 0) error.retryAfterMs = seconds * 1000;
400
+ }
401
+ throw error;
402
+ }
403
+
404
+ return json;
405
+ } catch (err) {
406
+ const elapsedMs = Date.now() - startedAt;
407
+ const isAbort = err instanceof Error && err.name === 'AbortError';
408
+ if (timedOutByUs && isAbort) {
409
+ // Our own deadline fired (not the caller's stop signal). Convert the
410
+ // opaque "This operation was aborted" into a typed, loggable timeout
411
+ // that records whether a response had started.
412
+ const phase = gotResponse ? 'reading-body' : 'awaiting-response';
413
+ console.error(
414
+ `[MAX API] ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`,
415
+ );
416
+ throw new MaxRequestTimeoutError(method, path, elapsedMs, phase);
417
+ }
418
+ if (!isAbort && !(err instanceof MaxApiError)) {
419
+ // Network-level failure (DNS/TCP/TLS/reset): record timing so a connect
420
+ // stall is distinguishable from a response-wait stall in the log.
421
+ console.error(
422
+ `[MAX API] ${method} ${path} failed after ${elapsedMs}ms: ${(err as Error).name}`,
423
+ );
424
+ }
425
+ throw err;
426
+ } finally {
427
+ clearTimeout(timeout);
428
+ }
429
+ }
430
+
431
+ private async request<T>(
432
+ method: string,
433
+ path: string,
434
+ params?: Record<string, string | number | boolean | undefined | null>,
435
+ body?: unknown,
436
+ timeoutMs?: number,
437
+ retryAttempts?: number,
438
+ externalSignal?: AbortSignal,
439
+ ): Promise<T> {
440
+ const attempts = retryAttempts ?? this.retryAttempts;
441
+ if (attempts <= 1) {
442
+ return this.requestOnce<T>(method, path, params, body, timeoutMs, externalSignal);
443
+ }
444
+ const idempotent = IDEMPOTENT_METHODS.has(method);
445
+ return retryAsync(
446
+ () => this.requestOnce<T>(method, path, params, body, timeoutMs, externalSignal),
447
+ {
448
+ attempts,
449
+ minDelayMs: 500,
450
+ maxDelayMs: 5_000,
451
+ label: `MAX ${method} ${path}`,
452
+ shouldRetry: (err) => isRetryableError(err, idempotent),
453
+ retryAfterMs: (err) => (err instanceof MaxApiError ? err.retryAfterMs : undefined),
454
+ },
455
+ );
456
+ }
457
+
458
+ // ── Bot info ──
459
+
460
+ async getMe(): Promise<MaxUser> {
461
+ return this.request<MaxUser>('GET', '/me');
462
+ }
463
+
464
+ // ── Messages ──
465
+
466
+ async sendMessage(
467
+ body: MaxNewMessageBody,
468
+ params: { chat_id?: number; user_id?: number; disable_link_preview?: boolean },
469
+ ): Promise<MaxSendResult> {
470
+ const chat = params.chat_id != null ? `c${params.chat_id}` : `u${params.user_id}`;
471
+ await this.sendLimiter?.acquire(`${this.tokenKey}:${chat}`);
472
+ return this.request<MaxSendResult>(
473
+ 'POST',
474
+ '/messages',
475
+ params as Record<string, string | number>,
476
+ body,
477
+ );
478
+ }
479
+
480
+ async editMessage(messageId: string, body: MaxNewMessageBody): Promise<MaxSimpleResult> {
481
+ return this.request<MaxSimpleResult>('PUT', '/messages', { message_id: messageId }, body);
482
+ }
483
+
484
+ async deleteMessage(messageId: string): Promise<MaxSimpleResult> {
485
+ return this.request<MaxSimpleResult>('DELETE', '/messages', { message_id: messageId });
486
+ }
487
+
488
+ /**
489
+ * List messages in a chat, newest first (GET /messages). `before`/`after` are
490
+ * Unix-time bounds in ms; they replace the deprecated `from`/`to`.
491
+ */
492
+ async getMessages(
493
+ chatId: number,
494
+ params?: {
495
+ message_ids?: string[];
496
+ before?: number;
497
+ after?: number;
498
+ count?: number;
499
+ },
500
+ ): Promise<{ messages: MaxMessage[] }> {
501
+ const qp: Record<string, string | number> = { chat_id: chatId };
502
+ if (params?.message_ids) qp.message_ids = params.message_ids.join(',');
503
+ if (params?.before != null) qp.before = params.before;
504
+ if (params?.after != null) qp.after = params.after;
505
+ if (params?.count) qp.count = params.count;
506
+ return this.request('GET', '/messages', qp);
507
+ }
508
+
509
+ /** Get a single message by its mid (GET /messages/{messageId}); 404 if gone or inaccessible. */
510
+ async getMessageById(messageId: string): Promise<MaxMessage> {
511
+ return this.request<MaxMessage>('GET', `/messages/${encodeURIComponent(messageId)}`);
512
+ }
513
+
514
+ // ── Chats ──
515
+
516
+ /** Get chat info by numeric id (GET /chats/{chatId}). */
517
+ async getChat(chatId: number): Promise<MaxChat> {
518
+ return this.request('GET', `/chats/${chatId}`);
519
+ }
520
+
521
+ /** Bot's own membership in a chat — is_admin matters: long polling delivers group updates only to admin bots. */
522
+ async getMembership(chatId: number): Promise<MaxChatMember> {
523
+ return this.request('GET', `/chats/${chatId}/members/me`);
524
+ }
525
+
526
+ // ── Pinned messages ──
527
+
528
+ async getPinnedMessage(chatId: number): Promise<{ message?: MaxMessage | null }> {
529
+ return this.request('GET', `/chats/${chatId}/pin`);
530
+ }
531
+
532
+ async pinMessage(chatId: number, messageId: string, notify?: boolean): Promise<MaxSimpleResult> {
533
+ return this.request('PUT', `/chats/${chatId}/pin`, undefined, {
534
+ message_id: messageId,
535
+ ...(notify != null ? { notify } : {}),
536
+ });
537
+ }
538
+
539
+ async unpinMessage(chatId: number): Promise<MaxSimpleResult> {
540
+ return this.request('DELETE', `/chats/${chatId}/pin`);
541
+ }
542
+
543
+ // ── Chat actions ──
544
+
545
+ async sendAction(chatId: number, action: MaxSenderAction): Promise<MaxSimpleResult> {
546
+ return this.request('POST', `/chats/${chatId}/actions`, undefined, { action });
547
+ }
548
+
549
+ // ── Callbacks ──
550
+
551
+ async answerCallback(
552
+ callbackId: string,
553
+ body?: { message?: MaxNewMessageBody; notification?: string },
554
+ ): Promise<MaxSimpleResult> {
555
+ return this.request('POST', '/answers', { callback_id: callbackId }, body);
556
+ }
557
+
558
+ // ── Updates (long polling) ──
559
+
560
+ async getUpdates(params?: {
561
+ limit?: number;
562
+ timeout?: number;
563
+ marker?: number | null;
564
+ types?: MaxUpdateType[];
565
+ /** Channel stop signal — aborts the in-flight long poll immediately. */
566
+ signal?: AbortSignal;
567
+ }): Promise<MaxUpdatesResponse> {
568
+ const qp: Record<string, string | number> = {};
569
+ if (params?.limit) qp.limit = params.limit;
570
+ if (params?.timeout != null) qp.timeout = params.timeout;
571
+ if (params?.marker != null) qp.marker = params.marker;
572
+ if (params?.types?.length) qp.types = params.types.join(',');
573
+
574
+ // Long polling needs a longer timeout; the polling loop owns retries
575
+ const pollTimeout = ((params?.timeout ?? 30) + 5) * 1000;
576
+ return this.request('GET', '/updates', qp, undefined, pollTimeout, 0, params?.signal);
577
+ }
578
+
579
+ // ── Videos ──
580
+
581
+ /** Playback info for an inbound video attachment (GET /videos/{videoToken}). */
582
+ async getVideoInfo(videoToken: string): Promise<MaxVideoInfo> {
583
+ return this.request('GET', `/videos/${encodeURIComponent(videoToken)}`);
584
+ }
585
+
586
+ // ── Subscriptions (webhooks) ──
587
+
588
+ async getSubscriptions(): Promise<MaxSubscriptionsResponse> {
589
+ return this.request('GET', '/subscriptions');
590
+ }
591
+
592
+ async subscribe(params: {
593
+ url: string;
594
+ update_types?: string[];
595
+ secret?: string;
596
+ }): Promise<MaxSimpleResult> {
597
+ return this.request('POST', '/subscriptions', undefined, params);
598
+ }
599
+
600
+ async unsubscribe(subscriptionUrl: string): Promise<MaxSimpleResult> {
601
+ return this.request('DELETE', '/subscriptions', { url: subscriptionUrl });
602
+ }
603
+
604
+ // ── Commands ──
605
+
606
+ /**
607
+ * Replace the bot command list (PATCH /me/commands; PATCH /me answers 404).
608
+ * Limits: 32 commands, name ≤ 64 chars (no leading slash), description ≤ 128 chars.
609
+ */
610
+ async setMyCommands(commands: MaxBotCommand[]): Promise<MaxBotCommandsInfo> {
611
+ const normalized = commands
612
+ .map((cmd) => ({
613
+ name: cmd.name.replace(/^\//, '').slice(0, 64),
614
+ ...(cmd.description ? { description: cmd.description.slice(0, 128) } : {}),
615
+ }))
616
+ .filter((cmd) => cmd.name.length > 0)
617
+ .slice(0, 32);
618
+ return this.request<MaxBotCommandsInfo>('PATCH', '/me/commands', undefined, {
619
+ commands: normalized,
620
+ });
621
+ }
622
+
623
+ // ── Upload ──
624
+
625
+ async getUploadUrl(type: 'image' | 'video' | 'audio' | 'file'): Promise<MaxUploadUrlResponse> {
626
+ return this.request('POST', `/uploads`, { type });
627
+ }
628
+
629
+ /**
630
+ * Upload media file to MAX.
631
+ * MAX upload requires multipart/form-data with field "data".
632
+ * Response contains a token (in photos/videos/etc) to use in attachments.
633
+ *
634
+ * @param type Media type (image, video, audio, file)
635
+ * @param data File path (string) or buffer (Buffer/Uint8Array)
636
+ * @param contentType MIME type (optional, auto-detected for common types)
637
+ * @returns Upload result with token (for use in attachment payload)
638
+ */
639
+ async uploadMedia(
640
+ type: 'image' | 'video' | 'audio' | 'file',
641
+ data: string | Buffer | Uint8Array,
642
+ contentType?: string,
643
+ ): Promise<{ token: string; url?: string }> {
644
+ // Step 1: Get upload URL and attachment token.
645
+ // MAX returns the attachment token together with the upload URL. The
646
+ // following upload host may answer with XML like `<retval>1</retval>` and
647
+ // does not repeat that token.
648
+ const uploadInfo = await this.getUploadUrl(type);
649
+ const uploadUrl = uploadInfo.url;
650
+
651
+ // Step 2: Load file if data is a path
652
+ let fileBuffer: Buffer;
653
+ let mimeType = contentType;
654
+ let fileName = 'file';
655
+
656
+ if (typeof data === 'string') {
657
+ // File path — read from disk
658
+ const fs = await import('fs/promises');
659
+ const path = await import('path');
660
+ fileBuffer = await fs.readFile(data);
661
+ fileName = path.basename(data);
662
+
663
+ // Auto-detect MIME type if not provided
664
+ if (!mimeType) {
665
+ const ext = data.split('.').pop()?.toLowerCase();
666
+ const mimeMap: Record<string, string> = {
667
+ jpg: 'image/jpeg',
668
+ jpeg: 'image/jpeg',
669
+ png: 'image/png',
670
+ gif: 'image/gif',
671
+ webp: 'image/webp',
672
+ heic: 'image/heic',
673
+ heif: 'image/heif',
674
+ tif: 'image/tiff',
675
+ tiff: 'image/tiff',
676
+ bmp: 'image/bmp',
677
+ mp4: 'video/mp4',
678
+ mov: 'video/quicktime',
679
+ avi: 'video/x-msvideo',
680
+ mkv: 'video/x-matroska',
681
+ webm: 'video/webm',
682
+ mp3: 'audio/mpeg',
683
+ wav: 'audio/wav',
684
+ ogg: 'audio/ogg',
685
+ m4a: 'audio/mp4',
686
+ aac: 'audio/aac',
687
+ flac: 'audio/flac',
688
+ opus: 'audio/opus',
689
+ pdf: 'application/pdf',
690
+ txt: 'text/plain',
691
+ };
692
+ mimeType = ext ? mimeMap[ext] : 'application/octet-stream';
693
+ }
694
+ } else {
695
+ // Already a buffer
696
+ fileBuffer = Buffer.from(data);
697
+ mimeType = mimeType ?? 'application/octet-stream';
698
+ }
699
+
700
+ // Step 3: POST file as multipart/form-data to upload URL. MAX upload hosts
701
+ // reject Node's native FormData in some cases with 412, while curl-style
702
+ // multipart with a known Content-Length is accepted.
703
+ const multipart = buildMultipartFileBody('data', fileName, mimeType, fileBuffer);
704
+
705
+ const uploadRes = await maxFetch(uploadUrl, {
706
+ method: 'POST',
707
+ headers: {
708
+ 'Content-Type': multipart.contentType,
709
+ 'Content-Length': String(multipart.body.byteLength),
710
+ },
711
+ body: multipart.body,
712
+ });
713
+
714
+ if (!uploadRes.ok) {
715
+ throw new MaxApiError(
716
+ `MAX media upload failed: ${uploadRes.status}`,
717
+ uploadRes.status,
718
+ await uploadRes.text().catch(() => null),
719
+ );
720
+ }
721
+
722
+ // Step 4: Parse response to extract token
723
+ // Image response: { photos: { "<id>": { token: "...", url: "..." } } }
724
+ // Video/audio/file may respond with a top-level token or a similar nested map
725
+ const result = (await uploadRes.json().catch(() => ({}))) as Record<string, unknown>;
726
+
727
+ // Try to find token from nested response structure
728
+ let token = typeof uploadInfo.token === 'string' ? uploadInfo.token : '';
729
+ let url: string | undefined;
730
+
731
+ let foundNested = false;
732
+ const nestedContainers = ['photos', 'videos', 'audios', 'files'] as const;
733
+ for (const key of nestedContainers) {
734
+ const container = result[key];
735
+ if (container && typeof container === 'object' && !Array.isArray(container)) {
736
+ const first = Object.values(
737
+ container as Record<string, { token?: string; url?: string }>,
738
+ )[0];
739
+ if (first?.token) {
740
+ token = first.token;
741
+ url = first.url;
742
+ foundNested = true;
743
+ break;
744
+ }
745
+ }
746
+ }
747
+ if (!foundNested && result.token && typeof result.token === 'string') {
748
+ token = result.token;
749
+ url = (result.url as string) ?? undefined;
750
+ }
751
+
752
+ if (!token) {
753
+ throw new MaxApiError('MAX media upload: no token in response', 200, result);
754
+ }
755
+
756
+ return { token, url };
757
+ }
758
+ }