@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/send.ts ADDED
@@ -0,0 +1,774 @@
1
+ /**
2
+ * Outbound message sending for MAX.
3
+ */
4
+
5
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
6
+ import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
7
+
8
+ import { resolveMaxAccount } from './accounts.js';
9
+ import {
10
+ MaxApi,
11
+ MaxApiError,
12
+ type MaxAttachment,
13
+ type MaxInlineKeyboardAttachment,
14
+ type MaxInlineKeyboardButton,
15
+ type MaxNewMessageBody,
16
+ MaxRequestTimeoutError,
17
+ type MaxSendResult,
18
+ type MaxStickerAttachment,
19
+ } from './api.js';
20
+ import { toMaxMarkdown } from './format.js';
21
+ import { withRemoteMediaTempFile } from './media-temp.js';
22
+
23
+ export type MaxSendButton = {
24
+ text: string;
25
+ /** Button type; default: "link" when url is set, otherwise "callback" */
26
+ type?:
27
+ | 'callback'
28
+ | 'link'
29
+ | 'message'
30
+ | 'clipboard'
31
+ | 'open_app'
32
+ | 'request_contact'
33
+ | 'request_geo_location';
34
+ payload?: string;
35
+ callback_data?: string;
36
+ url?: string;
37
+ /** open_app: public name of the bot wired to the mini app (falls back to `url`) */
38
+ webApp?: string;
39
+ };
40
+
41
+ export interface MaxSendOptions {
42
+ token?: string;
43
+ accountId?: string;
44
+ cfg?: OpenClawConfig;
45
+ replyToMessageId?: string;
46
+ format?: 'markdown' | 'html';
47
+ disableLinkPreview?: boolean;
48
+ notify?: boolean;
49
+ buttons?: MaxSendButton[][];
50
+ }
51
+
52
+ /** Send target: numeric chat id, or an explicit user id (user:<id> targets). */
53
+ export type MaxSendTarget = { chat_id: number } | { user_id: number };
54
+
55
+ /**
56
+ * Resolve a token from options or config.
57
+ */
58
+ function resolveToken(opts: MaxSendOptions): string {
59
+ if (opts.token) return opts.token;
60
+ if (opts.cfg) {
61
+ const account = resolveMaxAccount({ cfg: opts.cfg, accountId: opts.accountId });
62
+ if (account.token) return account.token;
63
+ }
64
+ throw new Error('MAX bot token not available');
65
+ }
66
+
67
+ /**
68
+ * Resolve a target string into API send params.
69
+ * Supported forms: "12345", "max:12345" (chat id), "user:12345" / "max:user:12345"
70
+ * (user id).
71
+ *
72
+ * "@username" and max.ru links are rejected with an explicit error: the Bot API
73
+ * schema has only GET /chats/{chatId} (numeric), and a link lookup answers
74
+ * `chat.not.found` live. Address groups by chat_id (from bot_added), users by
75
+ * "user:<id>".
76
+ */
77
+ export async function resolveMaxTarget(_api: MaxApi, to: string): Promise<MaxSendTarget> {
78
+ let normalized = to.trim();
79
+ if (normalized.startsWith('max:')) normalized = normalized.slice(4);
80
+
81
+ if (normalized.startsWith('user:')) {
82
+ const userId = Number(normalized.slice(5));
83
+ if (Number.isNaN(userId)) throw new Error(`Invalid MAX target: ${to}`);
84
+ return { user_id: userId };
85
+ }
86
+
87
+ const chatId = Number(normalized);
88
+ if (!Number.isNaN(chatId) && normalized !== '') {
89
+ return { chat_id: chatId };
90
+ }
91
+
92
+ if (normalized.startsWith('@') || /(^|\/\/|www\.)max\.ru\//i.test(normalized)) {
93
+ throw new Error(
94
+ `MAX API does not resolve @username or chat links ("${to}"); ` +
95
+ 'use a numeric chat_id for chats and channels or user:<id> for users',
96
+ );
97
+ }
98
+
99
+ throw new Error(`Invalid MAX target: ${to}`);
100
+ }
101
+
102
+ /** Button types the plugin builds (schema.yaml Button.discriminator). */
103
+ export const MAX_SEND_BUTTON_TYPES: ReadonlySet<string> = new Set([
104
+ 'callback',
105
+ 'link',
106
+ 'message',
107
+ 'clipboard',
108
+ 'open_app',
109
+ 'request_contact',
110
+ 'request_geo_location',
111
+ ]);
112
+
113
+ /**
114
+ * Parse loosely typed button rows (`channelData.max.buttons`, the message
115
+ * tool's `buttons` param): [[{text|label, type?, payload?, callback_data?,
116
+ * url?, webApp|web_app?}]]. A bare button in place of a row is one row;
117
+ * non-object items and buttons without text are dropped.
118
+ */
119
+ export function readMaxSendButtons(rawButtons: unknown): MaxSendButton[][] | undefined {
120
+ if (!Array.isArray(rawButtons)) return undefined;
121
+
122
+ const rows = rawButtons
123
+ .map((row) => {
124
+ const items = Array.isArray(row) ? row : [row];
125
+ return items
126
+ .filter(
127
+ (button): button is Record<string, unknown> =>
128
+ Boolean(button) && typeof button === 'object' && !Array.isArray(button),
129
+ )
130
+ .map((button) => ({
131
+ text: String(button.text ?? button.label ?? ''),
132
+ type: MAX_SEND_BUTTON_TYPES.has(String(button.type))
133
+ ? (String(button.type) as MaxSendButton['type'])
134
+ : undefined,
135
+ payload: button.payload != null ? String(button.payload) : undefined,
136
+ callback_data: button.callback_data != null ? String(button.callback_data) : undefined,
137
+ url: button.url != null ? String(button.url) : undefined,
138
+ webApp:
139
+ button.webApp != null
140
+ ? String(button.webApp)
141
+ : button.web_app != null
142
+ ? String(button.web_app)
143
+ : undefined,
144
+ }))
145
+ .filter((button) => button.text.trim().length > 0);
146
+ })
147
+ .filter((row) => row.length > 0);
148
+
149
+ return rows.length > 0 ? rows : undefined;
150
+ }
151
+
152
+ export function readMaxChannelButtons(channelData: unknown): MaxSendButton[][] | undefined {
153
+ if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
154
+ return undefined;
155
+ const maxData = (channelData as Record<string, unknown>).max;
156
+ if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return undefined;
157
+ return readMaxSendButtons((maxData as Record<string, unknown>).buttons);
158
+ }
159
+
160
+ /** Extra per-message options passed via channelData.max (notify, link preview). */
161
+ export function readMaxChannelSendOptions(channelData: unknown): {
162
+ notify?: boolean;
163
+ disableLinkPreview?: boolean;
164
+ } {
165
+ if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData)) return {};
166
+ const maxData = (channelData as Record<string, unknown>).max;
167
+ if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return {};
168
+ const data = maxData as Record<string, unknown>;
169
+ const result: { notify?: boolean; disableLinkPreview?: boolean } = {};
170
+ if (typeof data.notify === 'boolean') result.notify = data.notify;
171
+ if (data.silent === true) result.notify = false;
172
+ if (typeof data.disableLinkPreview === 'boolean')
173
+ result.disableLinkPreview = data.disableLinkPreview;
174
+ return result;
175
+ }
176
+
177
+ function buildMaxButton(btn: MaxSendButton): MaxInlineKeyboardButton {
178
+ const type = btn.type ?? (btn.url ? 'link' : 'callback');
179
+ const payload = btn.payload ?? btn.callback_data;
180
+ switch (type) {
181
+ case 'link':
182
+ return { type: 'link', text: btn.text, url: btn.url ?? '' };
183
+ case 'message':
184
+ return { type: 'message', text: btn.text, ...(payload ? { payload } : {}) };
185
+ case 'clipboard':
186
+ return { type: 'clipboard', text: btn.text, payload: payload ?? btn.text };
187
+ case 'open_app': {
188
+ // OpenAppButton has no `url`: the mini app is addressed by `web_app`
189
+ // (the wired bot's public name). A legacy `url` is carried over there.
190
+ const webApp = btn.webApp ?? btn.url;
191
+ return {
192
+ type: 'open_app',
193
+ text: btn.text,
194
+ ...(webApp ? { web_app: webApp } : {}),
195
+ ...(payload ? { payload } : {}),
196
+ };
197
+ }
198
+ case 'request_contact':
199
+ return { type: 'request_contact', text: btn.text };
200
+ case 'request_geo_location':
201
+ return { type: 'request_geo_location', text: btn.text };
202
+ case 'callback':
203
+ default:
204
+ return {
205
+ type: 'callback',
206
+ text: btn.text,
207
+ payload: payload ?? btn.text,
208
+ };
209
+ }
210
+ }
211
+
212
+ function buildInlineKeyboard(buttons: MaxSendButton[][]): MaxInlineKeyboardAttachment {
213
+ return {
214
+ type: 'inline_keyboard',
215
+ payload: {
216
+ buttons: buttons.map((row) => row.map((btn) => buildMaxButton(btn))),
217
+ },
218
+ };
219
+ }
220
+
221
+ /** Apply MAX markdown dialect conversion when sending formatted text. */
222
+ function formatOutboundText(text: string, format?: 'markdown' | 'html'): string {
223
+ if (format === 'markdown' && text) return toMaxMarkdown(text);
224
+ return text;
225
+ }
226
+
227
+ type MaxBodyAttachments = NonNullable<MaxNewMessageBody['attachments']>;
228
+
229
+ /**
230
+ * NewMessageBody: text (MAX markdown dialect) with its format when `text` is
231
+ * given (attachment-only bodies carry neither), notify, attachments and the
232
+ * reply link.
233
+ */
234
+ function buildMaxBody(
235
+ opts: MaxSendOptions,
236
+ text: string | undefined,
237
+ attachments?: MaxBodyAttachments,
238
+ ): MaxNewMessageBody {
239
+ const body: MaxNewMessageBody =
240
+ text === undefined
241
+ ? { notify: opts.notify }
242
+ : {
243
+ text: formatOutboundText(text, opts.format) || undefined,
244
+ format: opts.format ?? undefined,
245
+ notify: opts.notify,
246
+ };
247
+ if (attachments?.length) body.attachments = attachments;
248
+ if (opts.replyToMessageId) {
249
+ body.link = { type: 'reply', mid: opts.replyToMessageId };
250
+ }
251
+ return body;
252
+ }
253
+
254
+ /** Text body with the inline keyboard from `opts.buttons`. */
255
+ function buildMaxTextBody(text: string, opts: MaxSendOptions = {}): MaxNewMessageBody {
256
+ return buildMaxBody(
257
+ opts,
258
+ text,
259
+ opts.buttons?.length ? [buildInlineKeyboard(opts.buttons)] : undefined,
260
+ );
261
+ }
262
+
263
+ function buildSendParams(
264
+ target: MaxSendTarget,
265
+ opts: MaxSendOptions,
266
+ ): { chat_id?: number; user_id?: number; disable_link_preview?: boolean } {
267
+ const params: { chat_id?: number; user_id?: number; disable_link_preview?: boolean } = {
268
+ ...target,
269
+ };
270
+ if (opts.disableLinkPreview) {
271
+ params.disable_link_preview = true;
272
+ }
273
+ return params;
274
+ }
275
+
276
+ export type MaxSendOutcome = { messageId: string; raw: MaxSendResult };
277
+
278
+ /**
279
+ * The one POST /messages path of every send* helper: resolve the target, build
280
+ * the body (after the target, so a bad target fails before any upload), send —
281
+ * the per-chat limiter lives in MaxApi.sendMessage — through the caller's retry
282
+ * policy, and report the sent mid. typing_on is not sent here: the inbound
283
+ * pipeline sends it once per handled message.
284
+ */
285
+ async function sendWithBody(params: {
286
+ api: MaxApi;
287
+ to: string;
288
+ opts: MaxSendOptions;
289
+ body: MaxNewMessageBody | (() => Promise<MaxNewMessageBody>);
290
+ retry?: (send: () => Promise<MaxSendResult>) => Promise<MaxSendResult>;
291
+ }): Promise<MaxSendOutcome> {
292
+ const { api, to, opts, retry } = params;
293
+ const target = await resolveMaxTarget(api, to);
294
+ const body = typeof params.body === 'function' ? await params.body() : params.body;
295
+ const sendTo = (to: MaxSendTarget) => {
296
+ const sendParams = buildSendParams(to, opts);
297
+ const send = () => api.sendMessage(body, sendParams);
298
+ return retry ? retry(send) : send();
299
+ };
300
+ let result: MaxSendResult;
301
+ try {
302
+ result = await sendTo(target);
303
+ } catch (err) {
304
+ // A bare positive number is ambiguous: agents often pass a user id where
305
+ // a chat id is expected (the directory hands out user ids of DM peers).
306
+ // MAX rejected the send outright, so nothing was delivered and one retry
307
+ // as user_id cannot duplicate.
308
+ if (!('chat_id' in target) || target.chat_id <= 0 || !isChatNotFound(err)) throw err;
309
+ result = await sendTo({ user_id: target.chat_id });
310
+ }
311
+ return {
312
+ messageId: result.message?.body?.mid ?? '',
313
+ raw: result,
314
+ };
315
+ }
316
+
317
+ /** MAX answer for a chat_id that names no chat of the bot (e.g. a user id). */
318
+ export function isChatNotFound(err: unknown): boolean {
319
+ if (!(err instanceof MaxApiError)) return false;
320
+ if (err.code === 'dialog.not.found' || err.code === 'chat.not.found') return true;
321
+ return /\b(dialog|chat)\.not\.found\b/.test(err.message);
322
+ }
323
+
324
+ /**
325
+ * A client-side timeout aborts before the request reaches MAX: aborted sends
326
+ * never appear in the chat and never duplicate (verified against the live chat
327
+ * history). So one retry on a fresh connection is duplicate-safe and recovers
328
+ * the intermittent in-process send stall. Scoped to text sends on purpose —
329
+ * media is not retried here to avoid re-uploading the file.
330
+ */
331
+ async function retryOnceOnTimeout(send: () => Promise<MaxSendResult>): Promise<MaxSendResult> {
332
+ try {
333
+ return await send();
334
+ } catch (err) {
335
+ if (!(err instanceof MaxRequestTimeoutError)) throw err;
336
+ console.error(
337
+ `[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`,
338
+ );
339
+ return send();
340
+ }
341
+ }
342
+
343
+ /** MAX processes video/file uploads asynchronously: retry the send (not the upload) while attachment.not.ready. */
344
+ function retryWhileAttachmentNotReady(send: () => Promise<MaxSendResult>): Promise<MaxSendResult> {
345
+ return retryAsync(send, {
346
+ attempts: 6,
347
+ minDelayMs: 1_500,
348
+ maxDelayMs: 4_000,
349
+ label: 'MAX send media (attachment.not.ready)',
350
+ shouldRetry: (err) => isAttachmentNotReady(err),
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Send a text message to a MAX chat or user.
356
+ */
357
+ export async function sendMaxMessage(
358
+ to: string,
359
+ text: string,
360
+ opts: MaxSendOptions = {},
361
+ ): Promise<MaxSendOutcome> {
362
+ const api = new MaxApi({ token: resolveToken(opts) });
363
+ return sendWithBody({
364
+ api,
365
+ to,
366
+ opts,
367
+ body: buildMaxTextBody(text, opts),
368
+ retry: retryOnceOnTimeout,
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Answer a MAX callback. Unlike /messages, /answers is scoped by callback_id
374
+ * and works even when the callback update does not expose a sendable chat_id.
375
+ */
376
+ export async function answerMaxCallback(
377
+ callbackId: string,
378
+ text: string,
379
+ opts: MaxSendOptions & { notification?: string } = {},
380
+ ): Promise<void> {
381
+ const token = resolveToken(opts);
382
+ const api = new MaxApi({ token });
383
+ const body = buildMaxTextBody(text, opts);
384
+ await api.answerCallback(callbackId, {
385
+ ...(text || opts.buttons?.length ? { message: body } : {}),
386
+ ...(opts.notification ? { notification: opts.notification } : {}),
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Edit an existing MAX message. PUT /messages keeps the current attachments
392
+ * when `attachments` is absent/null and deletes them all on an empty list, so
393
+ * the keyboard is sent only when `buttons` are given (it then replaces the
394
+ * message attachments, e.g. adds buttons to a text-only stream draft).
395
+ */
396
+ export async function editMaxMessage(
397
+ messageId: string,
398
+ text: string,
399
+ opts: MaxSendOptions = {},
400
+ ): Promise<void> {
401
+ const token = resolveToken(opts);
402
+ const api = new MaxApi({ token });
403
+
404
+ await api.editMessage(messageId, {
405
+ text: formatOutboundText(text, opts.format),
406
+ format: opts.format ?? undefined,
407
+ ...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Delete a MAX message.
413
+ */
414
+ export async function deleteMaxMessage(
415
+ messageId: string,
416
+ opts: MaxSendOptions = {},
417
+ ): Promise<void> {
418
+ const token = resolveToken(opts);
419
+ const api = new MaxApi({ token });
420
+
421
+ await api.deleteMessage(messageId);
422
+ }
423
+
424
+ /** Pin result: MAX has no pinned messages in dialogs (1:1 chats). */
425
+ export type MaxPinOutcome = { pinned: true } | { pinned: false; reason: string };
426
+
427
+ export const MAX_PIN_DIALOG_REASON = 'MAX does not support pinned messages in dialogs';
428
+
429
+ /** 400 proto.payload "Method is not available for dialogs" from /chats/{id}/pin. */
430
+ function isPinUnavailableForDialog(err: unknown): boolean {
431
+ if (!(err instanceof MaxApiError)) return false;
432
+ const message =
433
+ typeof (err.body as { message?: unknown })?.message === 'string'
434
+ ? String((err.body as { message?: unknown }).message)
435
+ : err.message;
436
+ return /not available for dialogs/i.test(message);
437
+ }
438
+
439
+ /**
440
+ * Pin a message in a MAX chat. Dialogs cannot have pinned messages (MAX
441
+ * answers 400 "Method is not available for dialogs"), so a dialog target is
442
+ * reported as `{ pinned: false }` without calling the pin API: `user:<id>`
443
+ * targets are always dialogs, negative ids are groups/channels, and a
444
+ * positive chat id is checked through `chatType` (the recipient of the sent
445
+ * message) or GET /chats/{id}.
446
+ */
447
+ export async function pinMaxMessage(
448
+ to: string,
449
+ messageId: string,
450
+ opts: MaxSendOptions & {
451
+ pinNotify?: boolean;
452
+ /** recipient.chat_type of the sent message, when the caller has it. */
453
+ chatType?: string;
454
+ /** Checked synchronously right before each MAX request (delivery-owner guard). */
455
+ beforeRequest?: () => void;
456
+ } = {},
457
+ ): Promise<MaxPinOutcome> {
458
+ const token = resolveToken(opts);
459
+ const api = new MaxApi({ token });
460
+ const target = await resolveMaxTarget(api, to);
461
+ if (!('chat_id' in target) || opts.chatType === 'dialog') {
462
+ return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
463
+ }
464
+ const chatId = target.chat_id;
465
+ if (chatId > 0 && !opts.chatType) {
466
+ opts.beforeRequest?.();
467
+ const chatType = await api.getChat(chatId).then(
468
+ (chat) => chat.type,
469
+ () => undefined, // unknown: let the pin call decide
470
+ );
471
+ if (chatType === 'dialog') return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
472
+ }
473
+ opts.beforeRequest?.();
474
+ try {
475
+ await api.pinMessage(chatId, messageId, opts.pinNotify);
476
+ } catch (err) {
477
+ if (isPinUnavailableForDialog(err)) return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
478
+ throw err;
479
+ }
480
+ return { pinned: true };
481
+ }
482
+
483
+ export async function unpinMaxMessage(to: string, opts: MaxSendOptions = {}): Promise<void> {
484
+ const token = resolveToken(opts);
485
+ const api = new MaxApi({ token });
486
+ const target = await resolveMaxTarget(api, to);
487
+ if (!('chat_id' in target)) throw new Error('MAX unpin requires a chat id target');
488
+ await api.unpinMessage(target.chat_id);
489
+ }
490
+
491
+ // Extension → upload type routing. Everything else goes as a generic file.
492
+ const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'heic', 'tif', 'tiff', 'bmp'];
493
+ const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm'];
494
+ const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'opus'];
495
+
496
+ export function detectMaxMediaType(mediaPath: string): 'image' | 'video' | 'audio' | 'file' {
497
+ const ext = mediaPath.split('.').pop()?.toLowerCase() ?? '';
498
+ if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
499
+ if (VIDEO_EXTENSIONS.includes(ext)) return 'video';
500
+ if (AUDIO_EXTENSIONS.includes(ext)) return 'audio';
501
+ return 'file';
502
+ }
503
+
504
+ function isAttachmentNotReady(err: unknown): boolean {
505
+ if (!(err instanceof MaxApiError)) return false;
506
+ if (err.code === 'attachment.not.ready') return true;
507
+ const message =
508
+ typeof (err.body as { message?: unknown })?.message === 'string'
509
+ ? String((err.body as { message?: unknown }).message)
510
+ : '';
511
+ return /not\s*\.?\s*ready|not processed/i.test(message);
512
+ }
513
+
514
+ /** Images and videos per message (docs «Медиафайлы»: up to 12 in total). */
515
+ export const MAX_VISUAL_MEDIA_PER_MESSAGE = 12;
516
+
517
+ const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
518
+
519
+ export interface MaxMediaSendOptions extends MaxSendOptions {
520
+ /** Download cap for remote media that has to be uploaded (default 20 MB). */
521
+ mediaMaxBytes?: number;
522
+ }
523
+
524
+ function isRemoteUrl(source: string): boolean {
525
+ return source.startsWith('https://') || source.startsWith('http://');
526
+ }
527
+
528
+ /** https link whose path looks like an image: MAX can fetch it itself (image.payload.url). */
529
+ function isImageLink(source: string): boolean {
530
+ if (!source.startsWith('https://')) return false;
531
+ try {
532
+ return detectMaxMediaType(new URL(source).pathname) === 'image';
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
537
+
538
+ function mediaKind(source: string): 'image' | 'video' | 'audio' | 'file' {
539
+ if (!isRemoteUrl(source)) return detectMaxMediaType(source);
540
+ try {
541
+ return detectMaxMediaType(new URL(source).pathname);
542
+ } catch {
543
+ return 'file';
544
+ }
545
+ }
546
+
547
+ /** Upload a local path, or a remote URL through a temporary file. */
548
+ async function uploadMaxAttachment(
549
+ api: MaxApi,
550
+ source: string,
551
+ opts: MaxMediaSendOptions,
552
+ ): Promise<MaxAttachment> {
553
+ if (isRemoteUrl(source)) {
554
+ return withRemoteMediaTempFile(
555
+ source,
556
+ opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
557
+ async ({ path, contentType }) => {
558
+ const type = detectMaxMediaType(path);
559
+ const uploaded = await api.uploadMedia(type, path, contentType);
560
+ return { type, payload: { token: uploaded.token } };
561
+ },
562
+ );
563
+ }
564
+ const type = detectMaxMediaType(source);
565
+ const uploaded = await api.uploadMedia(type, source);
566
+ return { type, payload: { token: uploaded.token } };
567
+ }
568
+
569
+ /**
570
+ * One message with the given media. https image links go as image.payload.url
571
+ * (no download/upload round trip); if MAX refuses the message, those links are
572
+ * uploaded and the message is sent once more. MAX processes video/file uploads
573
+ * asynchronously — sendMessage may answer attachment.not.ready for a few
574
+ * seconds; retry the send (not the upload).
575
+ */
576
+ async function sendMaxAttachmentsMessage(
577
+ api: MaxApi,
578
+ to: string,
579
+ caption: string,
580
+ sources: string[],
581
+ opts: MaxMediaSendOptions,
582
+ ): Promise<MaxSendOutcome> {
583
+ const build = async (allowLinks: boolean): Promise<MaxNewMessageBody> => {
584
+ const attachments: MaxAttachment[] = [];
585
+ for (const source of sources) {
586
+ attachments.push(
587
+ allowLinks && isImageLink(source)
588
+ ? { type: 'image', payload: { url: source } }
589
+ : await uploadMaxAttachment(api, source, opts),
590
+ );
591
+ }
592
+ if (opts.buttons?.length) {
593
+ attachments.push(buildInlineKeyboard(opts.buttons) as unknown as MaxAttachment);
594
+ }
595
+ return buildMaxBody(opts, caption, attachments);
596
+ };
597
+ const send = (allowLinks: boolean) =>
598
+ sendWithBody({
599
+ api,
600
+ to,
601
+ opts,
602
+ body: () => build(allowLinks),
603
+ retry: retryWhileAttachmentNotReady,
604
+ });
605
+
606
+ if (!sources.some(isImageLink)) return send(false);
607
+ try {
608
+ return await send(true);
609
+ } catch (err) {
610
+ if (!(err instanceof MaxApiError)) throw err;
611
+ console.warn(`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`);
612
+ return send(false);
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Send a media message to MAX (with upload).
618
+ * @param to Chat ID or user ID
619
+ * @param caption Text caption
620
+ * @param mediaPath Local file path or URL (https image links are sent by URL)
621
+ * @param opts Send options
622
+ */
623
+ export async function sendMaxMediaMessage(
624
+ to: string,
625
+ caption: string,
626
+ mediaPath: string,
627
+ opts: MaxMediaSendOptions = {},
628
+ ): Promise<MaxSendOutcome> {
629
+ const token = resolveToken(opts);
630
+ const api = new MaxApi({ token });
631
+ return sendMaxAttachmentsMessage(api, to, caption, [mediaPath], opts);
632
+ }
633
+
634
+ /**
635
+ * Split media into messages MAX accepts: consecutive images/videos go together
636
+ * (up to 12 per message, an album), audio and files one per message. Order is
637
+ * kept.
638
+ */
639
+ export function groupMaxMedia(sources: string[]): string[][] {
640
+ const groups: string[][] = [];
641
+ let album: string[] = [];
642
+ const flush = () => {
643
+ if (album.length) groups.push(album);
644
+ album = [];
645
+ };
646
+ for (const source of sources) {
647
+ const kind = mediaKind(source);
648
+ if (kind === 'image' || kind === 'video') {
649
+ if (album.length >= MAX_VISUAL_MEDIA_PER_MESSAGE) flush();
650
+ album.push(source);
651
+ } else {
652
+ flush();
653
+ groups.push([source]);
654
+ }
655
+ }
656
+ flush();
657
+ return groups;
658
+ }
659
+
660
+ /**
661
+ * Send several media as few messages as MAX allows (albums of up to 12
662
+ * images/videos). Caption and reply link go on the first message, buttons on
663
+ * the last. Without `onError` the first failure throws; with it, a failed
664
+ * message is reported and the rest are still sent.
665
+ */
666
+ export async function sendMaxMediaGroup(
667
+ to: string,
668
+ caption: string,
669
+ sources: string[],
670
+ opts: MaxMediaSendOptions & { onError?: (err: unknown, sources: string[]) => void } = {},
671
+ ): Promise<{ messageIds: string[] }> {
672
+ const token = resolveToken(opts);
673
+ const api = new MaxApi({ token });
674
+ const { onError, ...sendOpts } = opts;
675
+ const groups = groupMaxMedia(sources);
676
+ const messageIds: string[] = [];
677
+ for (let index = 0; index < groups.length; index += 1) {
678
+ const first = index === 0;
679
+ const last = index === groups.length - 1;
680
+ try {
681
+ const sent = await sendMaxAttachmentsMessage(api, to, first ? caption : '', groups[index], {
682
+ ...sendOpts,
683
+ replyToMessageId: first ? sendOpts.replyToMessageId : undefined,
684
+ buttons: last ? sendOpts.buttons : undefined,
685
+ });
686
+ if (sent.messageId) messageIds.push(sent.messageId);
687
+ } catch (err) {
688
+ if (!onError) throw err;
689
+ onError(err, groups[index]);
690
+ }
691
+ }
692
+ return { messageIds };
693
+ }
694
+
695
+ /**
696
+ * Send a contact attachment to MAX.
697
+ */
698
+ export async function sendMaxContact(
699
+ to: string,
700
+ contact: { name: string; contactId?: number; vcfPhone?: string; vcfInfo?: string },
701
+ opts: MaxSendOptions = {},
702
+ ): Promise<MaxSendOutcome> {
703
+ const api = new MaxApi({ token: resolveToken(opts) });
704
+
705
+ // MAX API requires either contact_id (MAX user_id) or vcf_info (VCard string)
706
+ // Without either, returns 400 "Missing info for contact attachment".
707
+ // Field names follow ContactAttachmentRequestPayload (snake_case).
708
+ const payload: Record<string, unknown> = { name: contact.name };
709
+ if (contact.contactId != null) {
710
+ payload.contact_id = contact.contactId;
711
+ if (contact.vcfPhone) payload.vcf_phone = contact.vcfPhone;
712
+ } else if (contact.vcfInfo) {
713
+ payload.vcf_info = contact.vcfInfo;
714
+ } else {
715
+ // Generate VCard from name + phone
716
+ // Use literal \n escape sequence for JSON serialization
717
+ const vcfParts = ['BEGIN:VCARD', 'VERSION:3.0', `FN:${contact.name}`];
718
+ if (contact.vcfPhone) vcfParts.push(`TEL:${contact.vcfPhone}`);
719
+ vcfParts.push('END:VCARD');
720
+ payload.vcf_info = vcfParts.join('\n');
721
+ }
722
+
723
+ return sendWithBody({
724
+ api,
725
+ to,
726
+ opts,
727
+ body: buildMaxBody(opts, undefined, [{ type: 'contact', payload }]),
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Send a location attachment to MAX.
733
+ */
734
+ export async function sendMaxLocation(
735
+ to: string,
736
+ location: { latitude: number; longitude: number },
737
+ text?: string,
738
+ opts: MaxSendOptions = {},
739
+ ): Promise<MaxSendOutcome> {
740
+ const api = new MaxApi({ token: resolveToken(opts) });
741
+ const attachment: MaxAttachment = {
742
+ type: 'location',
743
+ latitude: location.latitude,
744
+ longitude: location.longitude,
745
+ };
746
+ return sendWithBody({
747
+ api,
748
+ to,
749
+ opts,
750
+ body: buildMaxBody(opts, text ?? '', [attachment]),
751
+ });
752
+ }
753
+
754
+ /**
755
+ * Send a sticker to MAX by sticker code.
756
+ * Sticker codes come from incoming sticker attachments (payload.code).
757
+ */
758
+ export async function sendMaxSticker(
759
+ to: string,
760
+ stickerCode: string,
761
+ opts: MaxSendOptions = {},
762
+ ): Promise<MaxSendOutcome> {
763
+ const api = new MaxApi({ token: resolveToken(opts) });
764
+ const stickerAttachment: MaxStickerAttachment = {
765
+ type: 'sticker',
766
+ payload: { code: stickerCode },
767
+ };
768
+ return sendWithBody({
769
+ api,
770
+ to,
771
+ opts,
772
+ body: buildMaxBody(opts, undefined, [stickerAttachment]),
773
+ });
774
+ }