@aspalagin/openclaw-max 0.7.3 → 0.8.1

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 (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
package/src/send.ts CHANGED
@@ -3,7 +3,16 @@
3
3
  */
4
4
 
5
5
  import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
6
+ import {
7
+ buildOutboundMediaLoadOptions,
8
+ type OutboundMediaAccess,
9
+ } from 'openclaw/plugin-sdk/media-runtime';
6
10
  import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
11
+ import {
12
+ isBlockedHostnameOrIp,
13
+ resolvePinnedHostnameWithPolicy,
14
+ } from 'openclaw/plugin-sdk/ssrf-runtime';
15
+ import { loadWebMediaRaw } from 'openclaw/plugin-sdk/web-media';
7
16
 
8
17
  import { resolveMaxAccount } from './accounts.js';
9
18
  import {
@@ -18,7 +27,8 @@ import {
18
27
  type MaxStickerAttachment,
19
28
  } from './api.js';
20
29
  import { toMaxMarkdown } from './format.js';
21
- import { withRemoteMediaTempFile } from './media-temp.js';
30
+ import { downloadMaxRemoteMedia, type MaxLoadedMedia, sanitizeMaxFileName } from './media-temp.js';
31
+ import { getMaxLogger } from './runtime.js';
22
32
 
23
33
  export type MaxSendButton = {
24
34
  text: string;
@@ -231,6 +241,32 @@ type MaxBodyAttachments = NonNullable<MaxNewMessageBody['attachments']>;
231
241
  * given (attachment-only bodies carry neither), notify, attachments and the
232
242
  * reply link.
233
243
  */
244
+ /** Account defaults for notifications and link previews (config `notify`, `disableLinkPreview`). */
245
+ export interface MaxSendDefaults {
246
+ notify?: boolean;
247
+ disableLinkPreview?: boolean;
248
+ }
249
+
250
+ /**
251
+ * Notification and link-preview options of one send. An explicit value of the
252
+ * call beats the account default: core's `silent` flag first, then
253
+ * channelData.max (`notify`, `silent`, `disableLinkPreview`). Unset fields stay
254
+ * unset, so MAX applies its own defaults (notify on, previews on).
255
+ */
256
+ export function resolveMaxSendFlags(
257
+ defaults: MaxSendDefaults,
258
+ explicit: { silent?: boolean; channelData?: unknown } = {},
259
+ ): { notify?: boolean; disableLinkPreview?: boolean } {
260
+ const fromData = readMaxChannelSendOptions(explicit.channelData);
261
+ const notify =
262
+ typeof explicit.silent === 'boolean' ? !explicit.silent : (fromData.notify ?? defaults.notify);
263
+ const disableLinkPreview = fromData.disableLinkPreview ?? defaults.disableLinkPreview;
264
+ return {
265
+ ...(notify !== undefined ? { notify } : {}),
266
+ ...(disableLinkPreview !== undefined ? { disableLinkPreview } : {}),
267
+ };
268
+ }
269
+
234
270
  function buildMaxBody(
235
271
  opts: MaxSendOptions,
236
272
  text: string | undefined,
@@ -280,7 +316,7 @@ export type MaxSendOutcome = { messageId: string; raw: MaxSendResult };
280
316
  * the body (after the target, so a bad target fails before any upload), send —
281
317
  * the per-chat limiter lives in MaxApi.sendMessage — through the caller's retry
282
318
  * policy, and report the sent mid. typing_on is not sent here: the inbound
283
- * pipeline sends it once per handled message.
319
+ * pipeline keeps it up for the agent turn.
284
320
  */
285
321
  async function sendWithBody(params: {
286
322
  api: MaxApi;
@@ -291,7 +327,12 @@ async function sendWithBody(params: {
291
327
  }): Promise<MaxSendOutcome> {
292
328
  const { api, to, opts, retry } = params;
293
329
  const target = await resolveMaxTarget(api, to);
294
- const body = typeof params.body === 'function' ? await params.body() : params.body;
330
+ let body = typeof params.body === 'function' ? await params.body() : params.body;
331
+ // MAX channels publish only with notifications (POST /messages: notify must
332
+ // be true or absent for a channel), so a silent send into a channel notifies.
333
+ if (body.notify === false && 'chat_id' in target && (await isMaxChannel(api, target.chat_id))) {
334
+ body = { ...body, notify: undefined };
335
+ }
295
336
  const sendTo = (to: MaxSendTarget) => {
296
337
  const sendParams = buildSendParams(to, opts);
297
338
  const send = () => api.sendMessage(body, sendParams);
@@ -314,6 +355,30 @@ async function sendWithBody(params: {
314
355
  };
315
356
  }
316
357
 
358
+ /** Chat id → is a channel. A chat never changes its type, so this is never invalidated. */
359
+ const channelChats = new Map<number, boolean>();
360
+
361
+ /**
362
+ * Whether chat_id names a MAX channel (GET /chats/{chatId}, cached). A chat
363
+ * the bot does not know is not a channel (the send falls back to user_id);
364
+ * after any other lookup failure assume a channel: a notified post is better
365
+ * than a send MAX rejects.
366
+ */
367
+ async function isMaxChannel(api: MaxApi, chatId: number): Promise<boolean> {
368
+ const cached = channelChats.get(chatId);
369
+ if (cached !== undefined) return cached;
370
+ let isChannel: boolean;
371
+ try {
372
+ isChannel = (await api.getChat(chatId)).type === 'channel';
373
+ } catch (err) {
374
+ if (!isChatNotFound(err)) return true;
375
+ isChannel = false;
376
+ }
377
+ if (channelChats.size >= 1000) channelChats.clear();
378
+ channelChats.set(chatId, isChannel);
379
+ return isChannel;
380
+ }
381
+
317
382
  /** MAX answer for a chat_id that names no chat of the bot (e.g. a user id). */
318
383
  export function isChatNotFound(err: unknown): boolean {
319
384
  if (!(err instanceof MaxApiError)) return false;
@@ -333,7 +398,7 @@ async function retryOnceOnTimeout(send: () => Promise<MaxSendResult>): Promise<M
333
398
  return await send();
334
399
  } catch (err) {
335
400
  if (!(err instanceof MaxRequestTimeoutError)) throw err;
336
- console.error(
401
+ getMaxLogger().error(
337
402
  `[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`,
338
403
  );
339
404
  return send();
@@ -352,7 +417,39 @@ function retryWhileAttachmentNotReady(send: () => Promise<MaxSendResult>): Promi
352
417
  }
353
418
 
354
419
  /**
355
- * Send a text message to a MAX chat or user.
420
+ * MAX refused a formatted text request as a whole (400) for a reason other
421
+ * than the target, an unready attachment or a dialog-only restriction, so the
422
+ * likely cause is the markup. MAX documents no dedicated code for bad markup:
423
+ * the known unrelated refusals are excluded instead. A 400 means nothing was
424
+ * created, so one plain-text resend cannot duplicate the message.
425
+ */
426
+ export function isMaxFormatRejection(err: unknown): boolean {
427
+ if (!(err instanceof MaxApiError) || err.status !== 400) return false;
428
+ return !isChatNotFound(err) && !isAttachmentNotReady(err) && !isPinUnavailableForDialog(err);
429
+ }
430
+
431
+ /**
432
+ * Run a formatted text request; when MAX refuses the markup, repeat it once
433
+ * without `format` (the text goes as written). The second failure propagates.
434
+ */
435
+ async function withPlainTextFallback<T>(
436
+ text: string,
437
+ opts: MaxSendOptions,
438
+ attempt: (opts: MaxSendOptions) => Promise<T>,
439
+ ): Promise<T> {
440
+ try {
441
+ return await attempt(opts);
442
+ } catch (err) {
443
+ if (!opts.format || !text.trim() || !isMaxFormatRejection(err)) throw err;
444
+ const code = err instanceof MaxApiError ? (err.code ?? err.status) : '';
445
+ getMaxLogger().warn(`[MAX] ${opts.format} refused (${code}); resending as plain text`);
446
+ return attempt({ ...opts, format: undefined });
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Send a text message to a MAX chat or user. A markup refusal is retried
452
+ * once as plain text.
356
453
  */
357
454
  export async function sendMaxMessage(
358
455
  to: string,
@@ -360,13 +457,15 @@ export async function sendMaxMessage(
360
457
  opts: MaxSendOptions = {},
361
458
  ): Promise<MaxSendOutcome> {
362
459
  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
- });
460
+ return withPlainTextFallback(text, opts, (sendOpts) =>
461
+ sendWithBody({
462
+ api,
463
+ to,
464
+ opts: sendOpts,
465
+ body: buildMaxTextBody(text, sendOpts),
466
+ retry: retryOnceOnTimeout,
467
+ }),
468
+ );
370
469
  }
371
470
 
372
471
  /**
@@ -380,10 +479,11 @@ export async function answerMaxCallback(
380
479
  ): Promise<void> {
381
480
  const token = resolveToken(opts);
382
481
  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 } : {}),
482
+ await withPlainTextFallback(text, opts, async (answerOpts) => {
483
+ await api.answerCallback(callbackId, {
484
+ ...(text || opts.buttons?.length ? { message: buildMaxTextBody(text, answerOpts) } : {}),
485
+ ...(opts.notification ? { notification: opts.notification } : {}),
486
+ });
387
487
  });
388
488
  }
389
489
 
@@ -391,7 +491,8 @@ export async function answerMaxCallback(
391
491
  * Edit an existing MAX message. PUT /messages keeps the current attachments
392
492
  * when `attachments` is absent/null and deletes them all on an empty list, so
393
493
  * 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).
494
+ * message attachments, e.g. adds buttons to a text-only stream draft). A
495
+ * markup refusal is retried once as plain text.
395
496
  */
396
497
  export async function editMaxMessage(
397
498
  messageId: string,
@@ -401,10 +502,12 @@ export async function editMaxMessage(
401
502
  const token = resolveToken(opts);
402
503
  const api = new MaxApi({ token });
403
504
 
404
- await api.editMessage(messageId, {
405
- text: formatOutboundText(text, opts.format),
406
- format: opts.format ?? undefined,
407
- ...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
505
+ await withPlainTextFallback(text, opts, async (editOpts) => {
506
+ await api.editMessage(messageId, {
507
+ text: formatOutboundText(text, editOpts.format),
508
+ format: editOpts.format ?? undefined,
509
+ ...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
510
+ });
408
511
  });
409
512
  }
410
513
 
@@ -516,13 +619,57 @@ export const MAX_VISUAL_MEDIA_PER_MESSAGE = 12;
516
619
 
517
620
  const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
518
621
 
622
+ /**
623
+ * Host media access for local files, as core hands it to message actions and
624
+ * the outbound adapter (ChannelMessageActionContext / ChannelOutboundContext).
625
+ */
626
+ export interface MaxLocalMediaAccess {
627
+ mediaAccess?: OutboundMediaAccess;
628
+ mediaLocalRoots?: readonly string[];
629
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
630
+ }
631
+
519
632
  export interface MaxMediaSendOptions extends MaxSendOptions {
520
- /** Download cap for remote media that has to be uploaded (default 20 MB). */
633
+ /** Size cap for media that has to be uploaded (default 20 MB). */
521
634
  mediaMaxBytes?: number;
635
+ /**
636
+ * Allowed roots and host reader for local paths. A local path is read only
637
+ * through core's guarded loader under these roots (realpath containment, so
638
+ * `..` and symlinks cannot escape); without them the gateway's default
639
+ * media roots apply.
640
+ */
641
+ localMedia?: MaxLocalMediaAccess;
642
+ /**
643
+ * Voice delivery (reply `audioAsVoice`, message tool `asVoice`): a single
644
+ * audio source goes as MAX's audio attachment even when only its MIME type
645
+ * says audio. MAX has no separate voice type: its clients show that
646
+ * attachment as a voice message.
647
+ */
648
+ asVoice?: boolean;
649
+ }
650
+
651
+ /**
652
+ * Media handed over as content (message tool `buffer`), not as a path or URL:
653
+ * the name and type come from the call; the bytes never touch the filesystem.
654
+ */
655
+ export interface MaxInlineMedia {
656
+ buffer: Buffer;
657
+ /** Sanitized file name (see sanitizeMaxFileName); its extension picks the MAX type. */
658
+ fileName: string;
659
+ contentType?: string;
660
+ }
661
+
662
+ /** A local path (guarded loader), a URL (guarded download) or inline content. */
663
+ export type MaxMediaSource = string | MaxInlineMedia;
664
+
665
+ /** Readable label of a source for errors and logs (never the content). */
666
+ export function describeMaxMediaSource(source: MaxMediaSource): string {
667
+ return typeof source === 'string' ? source : `${source.fileName} (inline)`;
522
668
  }
523
669
 
670
+ /** Any case: core's local-file loader would fetch such a URL itself, past the proxy. */
524
671
  function isRemoteUrl(source: string): boolean {
525
- return source.startsWith('https://') || source.startsWith('http://');
672
+ return /^https?:\/\//i.test(source);
526
673
  }
527
674
 
528
675
  /** https link whose path looks like an image: MAX can fetch it itself (image.payload.url). */
@@ -535,7 +682,26 @@ function isImageLink(source: string): boolean {
535
682
  }
536
683
  }
537
684
 
538
- function mediaKind(source: string): 'image' | 'video' | 'audio' | 'file' {
685
+ /**
686
+ * An image link handed to MAX by URL must point to a public host: not a
687
+ * private, loopback, link-local or metadata address, also after DNS
688
+ * resolution. Anything else is downloaded through the guarded fetcher (which
689
+ * refuses such hosts as well) and uploaded.
690
+ */
691
+ async function isPublicImageLink(source: MaxMediaSource): Promise<boolean> {
692
+ if (typeof source !== 'string' || !isImageLink(source)) return false;
693
+ try {
694
+ const { hostname } = new URL(source);
695
+ if (isBlockedHostnameOrIp(hostname)) return false;
696
+ await resolvePinnedHostnameWithPolicy(hostname);
697
+ return true;
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+
703
+ function mediaKind(source: MaxMediaSource): 'image' | 'video' | 'audio' | 'file' {
704
+ if (typeof source !== 'string') return detectMaxMediaType(source.fileName);
539
705
  if (!isRemoteUrl(source)) return detectMaxMediaType(source);
540
706
  try {
541
707
  return detectMaxMediaType(new URL(source).pathname);
@@ -544,50 +710,158 @@ function mediaKind(source: string): 'image' | 'video' | 'audio' | 'file' {
544
710
  }
545
711
  }
546
712
 
547
- /** Upload a local path, or a remote URL through a temporary file. */
548
- async function uploadMaxAttachment(
549
- api: MaxApi,
713
+ /**
714
+ * A local media file through core's guarded loader (loadWebMediaRaw): only
715
+ * under the allowed roots, through the host reader when core passed one,
716
+ * capped at the media size limit. A path outside the roots fails with
717
+ * LocalMediaAccessError ("Local media path is not under an allowed
718
+ * directory") before any byte is read.
719
+ */
720
+ async function loadLocalMaxMedia(
550
721
  source: string,
551
722
  opts: MaxMediaSendOptions,
723
+ ): Promise<MaxLoadedMedia> {
724
+ const loaded = await loadWebMediaRaw(
725
+ source,
726
+ buildOutboundMediaLoadOptions({
727
+ maxBytes: opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
728
+ mediaAccess: opts.localMedia?.mediaAccess,
729
+ mediaLocalRoots: opts.localMedia?.mediaLocalRoots,
730
+ mediaReadFile: opts.localMedia?.mediaReadFile,
731
+ optimizeImages: false,
732
+ }),
733
+ );
734
+ return {
735
+ buffer: loaded.buffer,
736
+ contentType: loaded.contentType,
737
+ fileName: sanitizeMaxFileName(loaded.fileName, loaded.contentType),
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Inline content within the media size limit, as an upload-ready file. The
743
+ * caller decodes it; the limit is checked again here for every path in.
744
+ */
745
+ function loadInlineMaxMedia(source: MaxInlineMedia, opts: MaxMediaSendOptions): MaxLoadedMedia {
746
+ const maxBytes = opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
747
+ if (source.buffer.byteLength > maxBytes) {
748
+ throw new Error(
749
+ `Inline media ${source.fileName} is ${source.buffer.byteLength} bytes, over the ${maxBytes}-byte limit (mediaMaxMb)`,
750
+ );
751
+ }
752
+ return {
753
+ buffer: source.buffer,
754
+ contentType: source.contentType,
755
+ fileName: sanitizeMaxFileName(source.fileName, source.contentType),
756
+ };
757
+ }
758
+
759
+ /**
760
+ * Load a local path (guarded loader), a remote URL (SSRF-guarded download)
761
+ * or inline content (size-checked).
762
+ */
763
+ async function loadMaxMediaSource(
764
+ api: MaxApi,
765
+ source: MaxMediaSource,
766
+ opts: MaxMediaSendOptions,
767
+ ): Promise<MaxLoadedMedia> {
768
+ return typeof source !== 'string'
769
+ ? loadInlineMaxMedia(source, opts)
770
+ : isRemoteUrl(source)
771
+ ? await downloadMaxRemoteMedia(
772
+ source,
773
+ opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
774
+ api.mediaProxyUrl(),
775
+ )
776
+ : await loadLocalMaxMedia(source, opts);
777
+ }
778
+
779
+ type MaxUploadType = ReturnType<typeof detectMaxMediaType>;
780
+
781
+ /**
782
+ * Upload type by file name; a voice send also takes audio known only by its
783
+ * MIME type (.oga, audio/webm) as audio.
784
+ */
785
+ function maxUploadType(media: MaxLoadedMedia, asVoice: boolean): MaxUploadType {
786
+ const type = detectMaxMediaType(media.fileName);
787
+ if (asVoice && media.contentType?.toLowerCase().startsWith('audio/')) return 'audio';
788
+ return type;
789
+ }
790
+
791
+ /**
792
+ * Upload loaded media. MAX may refuse an audio upload (a format it does not
793
+ * take as audio): nothing was sent yet, so the same bytes go as a file.
794
+ */
795
+ async function uploadMaxAttachment(
796
+ api: MaxApi,
797
+ media: MaxLoadedMedia,
798
+ type: MaxUploadType,
552
799
  ): 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
- },
800
+ try {
801
+ const uploaded = await api.uploadMedia(type, media.buffer, media.contentType, media.fileName);
802
+ return { type, payload: { token: uploaded.token } };
803
+ } catch (err) {
804
+ if (type !== 'audio' || !(err instanceof MaxApiError)) throw err;
805
+ getMaxLogger().warn(
806
+ `[MAX] audio upload of ${media.fileName} refused (${err.code ?? err.status}); sending as a file`,
562
807
  );
808
+ return uploadMaxAttachment(api, media, 'file');
563
809
  }
564
- const type = detectMaxMediaType(source);
565
- const uploaded = await api.uploadMedia(type, source);
566
- return { type, payload: { token: uploaded.token } };
567
810
  }
568
811
 
569
812
  /**
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).
813
+ * MAX refused a message with an audio attachment (400, also an audio still
814
+ * not processed after the attachment.not.ready retries): nothing was created,
815
+ * so one resend with the audio as a file cannot duplicate.
816
+ */
817
+ function isMaxAudioRefusal(err: unknown): boolean {
818
+ return err instanceof MaxApiError && err.status === 400 && !isChatNotFound(err);
819
+ }
820
+
821
+ /**
822
+ * One message with the given media. Image links to public https hosts go as
823
+ * image.payload.url (no download/upload round trip); if MAX refuses the
824
+ * message, those links are uploaded and the message is sent once more. MAX
825
+ * processes video/file uploads asynchronously — sendMessage may answer
826
+ * attachment.not.ready for a few seconds; retry the send (not the upload).
827
+ * Audio goes as MAX's audio attachment (shown as a voice message); if MAX
828
+ * refuses it, the audio is sent once more as a file, so the reply is not lost.
575
829
  */
576
830
  async function sendMaxAttachmentsMessage(
577
831
  api: MaxApi,
578
832
  to: string,
579
833
  caption: string,
580
- sources: string[],
834
+ sources: MaxMediaSource[],
581
835
  opts: MaxMediaSendOptions,
582
836
  ): Promise<MaxSendOutcome> {
837
+ const links = await Promise.all(sources.map(isPublicImageLink));
838
+ // Loaded once per message: a resend reuses the bytes instead of a new read.
839
+ const loaded = new Map<number, Promise<MaxLoadedMedia>>();
840
+ const asVoice = opts.asVoice === true && sources.length === 1;
841
+ let audioAsFile = false;
842
+ let hasAudio = false;
583
843
  const build = async (allowLinks: boolean): Promise<MaxNewMessageBody> => {
584
844
  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),
845
+ hasAudio = false;
846
+ for (const [index, source] of sources.entries()) {
847
+ if (allowLinks && links[index]) {
848
+ attachments.push({ type: 'image', payload: { url: source as string } });
849
+ continue;
850
+ }
851
+ let media = loaded.get(index);
852
+ if (!media) {
853
+ media = loadMaxMediaSource(api, source, opts);
854
+ loaded.set(index, media);
855
+ }
856
+ const loadedMedia = await media;
857
+ const type = maxUploadType(loadedMedia, asVoice);
858
+ const attachment = await uploadMaxAttachment(
859
+ api,
860
+ loadedMedia,
861
+ type === 'audio' && audioAsFile ? 'file' : type,
590
862
  );
863
+ if (attachment.type === 'audio') hasAudio = true;
864
+ attachments.push(attachment);
591
865
  }
592
866
  if (opts.buttons?.length) {
593
867
  attachments.push(buildInlineKeyboard(opts.buttons) as unknown as MaxAttachment);
@@ -603,12 +877,25 @@ async function sendMaxAttachmentsMessage(
603
877
  retry: retryWhileAttachmentNotReady,
604
878
  });
605
879
 
606
- if (!sources.some(isImageLink)) return send(false);
880
+ if (!links.some(Boolean)) {
881
+ try {
882
+ return await send(false);
883
+ } catch (err) {
884
+ if (!hasAudio || !isMaxAudioRefusal(err)) throw err;
885
+ getMaxLogger().warn(
886
+ `[MAX] message with audio refused (${(err as MaxApiError).code ?? 400}); sending the audio as a file`,
887
+ );
888
+ audioAsFile = true;
889
+ return send(false);
890
+ }
891
+ }
607
892
  try {
608
893
  return await send(true);
609
894
  } catch (err) {
610
895
  if (!(err instanceof MaxApiError)) throw err;
611
- console.warn(`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`);
896
+ getMaxLogger().warn(
897
+ `[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`,
898
+ );
612
899
  return send(false);
613
900
  }
614
901
  }
@@ -617,13 +904,13 @@ async function sendMaxAttachmentsMessage(
617
904
  * Send a media message to MAX (with upload).
618
905
  * @param to Chat ID or user ID
619
906
  * @param caption Text caption
620
- * @param mediaPath Local file path or URL (https image links are sent by URL)
907
+ * @param mediaPath Local file path (under the allowed roots), URL (public https image links are sent by URL) or inline content
621
908
  * @param opts Send options
622
909
  */
623
910
  export async function sendMaxMediaMessage(
624
911
  to: string,
625
912
  caption: string,
626
- mediaPath: string,
913
+ mediaPath: MaxMediaSource,
627
914
  opts: MaxMediaSendOptions = {},
628
915
  ): Promise<MaxSendOutcome> {
629
916
  const token = resolveToken(opts);
@@ -636,9 +923,9 @@ export async function sendMaxMediaMessage(
636
923
  * (up to 12 per message, an album), audio and files one per message. Order is
637
924
  * kept.
638
925
  */
639
- export function groupMaxMedia(sources: string[]): string[][] {
640
- const groups: string[][] = [];
641
- let album: string[] = [];
926
+ export function groupMaxMedia<T extends MaxMediaSource>(sources: T[]): T[][] {
927
+ const groups: T[][] = [];
928
+ let album: T[] = [];
642
929
  const flush = () => {
643
930
  if (album.length) groups.push(album);
644
931
  album = [];
@@ -666,8 +953,8 @@ export function groupMaxMedia(sources: string[]): string[][] {
666
953
  export async function sendMaxMediaGroup(
667
954
  to: string,
668
955
  caption: string,
669
- sources: string[],
670
- opts: MaxMediaSendOptions & { onError?: (err: unknown, sources: string[]) => void } = {},
956
+ sources: MaxMediaSource[],
957
+ opts: MaxMediaSendOptions & { onError?: (err: unknown, sources: MaxMediaSource[]) => void } = {},
671
958
  ): Promise<{ messageIds: string[] }> {
672
959
  const token = resolveToken(opts);
673
960
  const api = new MaxApi({ token });