@aspalagin/openclaw-max 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) 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.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
package/dist/src/send.js CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Outbound message sending for MAX.
3
3
  */
4
+ import { buildOutboundMediaLoadOptions, } from 'openclaw/plugin-sdk/media-runtime';
4
5
  import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
6
+ import { isBlockedHostnameOrIp, resolvePinnedHostnameWithPolicy, } from 'openclaw/plugin-sdk/ssrf-runtime';
7
+ import { loadWebMediaRaw } from 'openclaw/plugin-sdk/web-media';
5
8
  import { resolveMaxAccount } from './accounts.js';
6
9
  import { MaxApi, MaxApiError, MaxRequestTimeoutError, } from './api.js';
7
10
  import { toMaxMarkdown } from './format.js';
8
- import { withRemoteMediaTempFile } from './media-temp.js';
11
+ import { downloadMaxRemoteMedia, sanitizeMaxFileName } from './media-temp.js';
12
+ import { getMaxLogger } from './runtime.js';
9
13
  /**
10
14
  * Resolve a token from options or config.
11
15
  */
@@ -166,10 +170,20 @@ function formatOutboundText(text, format) {
166
170
  return text;
167
171
  }
168
172
  /**
169
- * NewMessageBody: text (MAX markdown dialect) with its format when `text` is
170
- * given (attachment-only bodies carry neither), notify, attachments and the
171
- * reply link.
173
+ * Notification and link-preview options of one send. An explicit value of the
174
+ * call beats the account default: core's `silent` flag first, then
175
+ * channelData.max (`notify`, `silent`, `disableLinkPreview`). Unset fields stay
176
+ * unset, so MAX applies its own defaults (notify on, previews on).
172
177
  */
178
+ export function resolveMaxSendFlags(defaults, explicit = {}) {
179
+ const fromData = readMaxChannelSendOptions(explicit.channelData);
180
+ const notify = typeof explicit.silent === 'boolean' ? !explicit.silent : (fromData.notify ?? defaults.notify);
181
+ const disableLinkPreview = fromData.disableLinkPreview ?? defaults.disableLinkPreview;
182
+ return {
183
+ ...(notify !== undefined ? { notify } : {}),
184
+ ...(disableLinkPreview !== undefined ? { disableLinkPreview } : {}),
185
+ };
186
+ }
173
187
  function buildMaxBody(opts, text, attachments) {
174
188
  const body = text === undefined
175
189
  ? { notify: opts.notify }
@@ -203,12 +217,17 @@ function buildSendParams(target, opts) {
203
217
  * the body (after the target, so a bad target fails before any upload), send —
204
218
  * the per-chat limiter lives in MaxApi.sendMessage — through the caller's retry
205
219
  * policy, and report the sent mid. typing_on is not sent here: the inbound
206
- * pipeline sends it once per handled message.
220
+ * pipeline keeps it up for the agent turn.
207
221
  */
208
222
  async function sendWithBody(params) {
209
223
  const { api, to, opts, retry } = params;
210
224
  const target = await resolveMaxTarget(api, to);
211
- const body = typeof params.body === 'function' ? await params.body() : params.body;
225
+ let body = typeof params.body === 'function' ? await params.body() : params.body;
226
+ // MAX channels publish only with notifications (POST /messages: notify must
227
+ // be true or absent for a channel), so a silent send into a channel notifies.
228
+ if (body.notify === false && 'chat_id' in target && (await isMaxChannel(api, target.chat_id))) {
229
+ body = { ...body, notify: undefined };
230
+ }
212
231
  const sendTo = (to) => {
213
232
  const sendParams = buildSendParams(to, opts);
214
233
  const send = () => api.sendMessage(body, sendParams);
@@ -232,6 +251,32 @@ async function sendWithBody(params) {
232
251
  raw: result,
233
252
  };
234
253
  }
254
+ /** Chat id → is a channel. A chat never changes its type, so this is never invalidated. */
255
+ const channelChats = new Map();
256
+ /**
257
+ * Whether chat_id names a MAX channel (GET /chats/{chatId}, cached). A chat
258
+ * the bot does not know is not a channel (the send falls back to user_id);
259
+ * after any other lookup failure assume a channel: a notified post is better
260
+ * than a send MAX rejects.
261
+ */
262
+ async function isMaxChannel(api, chatId) {
263
+ const cached = channelChats.get(chatId);
264
+ if (cached !== undefined)
265
+ return cached;
266
+ let isChannel;
267
+ try {
268
+ isChannel = (await api.getChat(chatId)).type === 'channel';
269
+ }
270
+ catch (err) {
271
+ if (!isChatNotFound(err))
272
+ return true;
273
+ isChannel = false;
274
+ }
275
+ if (channelChats.size >= 1000)
276
+ channelChats.clear();
277
+ channelChats.set(chatId, isChannel);
278
+ return isChannel;
279
+ }
235
280
  /** MAX answer for a chat_id that names no chat of the bot (e.g. a user id). */
236
281
  export function isChatNotFound(err) {
237
282
  if (!(err instanceof MaxApiError))
@@ -254,7 +299,7 @@ async function retryOnceOnTimeout(send) {
254
299
  catch (err) {
255
300
  if (!(err instanceof MaxRequestTimeoutError))
256
301
  throw err;
257
- console.error(`[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`);
302
+ getMaxLogger().error(`[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`);
258
303
  return send();
259
304
  }
260
305
  }
@@ -269,17 +314,46 @@ function retryWhileAttachmentNotReady(send) {
269
314
  });
270
315
  }
271
316
  /**
272
- * Send a text message to a MAX chat or user.
317
+ * MAX refused a formatted text request as a whole (400) for a reason other
318
+ * than the target, an unready attachment or a dialog-only restriction, so the
319
+ * likely cause is the markup. MAX documents no dedicated code for bad markup:
320
+ * the known unrelated refusals are excluded instead. A 400 means nothing was
321
+ * created, so one plain-text resend cannot duplicate the message.
322
+ */
323
+ export function isMaxFormatRejection(err) {
324
+ if (!(err instanceof MaxApiError) || err.status !== 400)
325
+ return false;
326
+ return !isChatNotFound(err) && !isAttachmentNotReady(err) && !isPinUnavailableForDialog(err);
327
+ }
328
+ /**
329
+ * Run a formatted text request; when MAX refuses the markup, repeat it once
330
+ * without `format` (the text goes as written). The second failure propagates.
331
+ */
332
+ async function withPlainTextFallback(text, opts, attempt) {
333
+ try {
334
+ return await attempt(opts);
335
+ }
336
+ catch (err) {
337
+ if (!opts.format || !text.trim() || !isMaxFormatRejection(err))
338
+ throw err;
339
+ const code = err instanceof MaxApiError ? (err.code ?? err.status) : '';
340
+ getMaxLogger().warn(`[MAX] ${opts.format} refused (${code}); resending as plain text`);
341
+ return attempt({ ...opts, format: undefined });
342
+ }
343
+ }
344
+ /**
345
+ * Send a text message to a MAX chat or user. A markup refusal is retried
346
+ * once as plain text.
273
347
  */
274
348
  export async function sendMaxMessage(to, text, opts = {}) {
275
349
  const api = new MaxApi({ token: resolveToken(opts) });
276
- return sendWithBody({
350
+ return withPlainTextFallback(text, opts, (sendOpts) => sendWithBody({
277
351
  api,
278
352
  to,
279
- opts,
280
- body: buildMaxTextBody(text, opts),
353
+ opts: sendOpts,
354
+ body: buildMaxTextBody(text, sendOpts),
281
355
  retry: retryOnceOnTimeout,
282
- });
356
+ }));
283
357
  }
284
358
  /**
285
359
  * Answer a MAX callback. Unlike /messages, /answers is scoped by callback_id
@@ -288,25 +362,29 @@ export async function sendMaxMessage(to, text, opts = {}) {
288
362
  export async function answerMaxCallback(callbackId, text, opts = {}) {
289
363
  const token = resolveToken(opts);
290
364
  const api = new MaxApi({ token });
291
- const body = buildMaxTextBody(text, opts);
292
- await api.answerCallback(callbackId, {
293
- ...(text || opts.buttons?.length ? { message: body } : {}),
294
- ...(opts.notification ? { notification: opts.notification } : {}),
365
+ await withPlainTextFallback(text, opts, async (answerOpts) => {
366
+ await api.answerCallback(callbackId, {
367
+ ...(text || opts.buttons?.length ? { message: buildMaxTextBody(text, answerOpts) } : {}),
368
+ ...(opts.notification ? { notification: opts.notification } : {}),
369
+ });
295
370
  });
296
371
  }
297
372
  /**
298
373
  * Edit an existing MAX message. PUT /messages keeps the current attachments
299
374
  * when `attachments` is absent/null and deletes them all on an empty list, so
300
375
  * the keyboard is sent only when `buttons` are given (it then replaces the
301
- * message attachments, e.g. adds buttons to a text-only stream draft).
376
+ * message attachments, e.g. adds buttons to a text-only stream draft). A
377
+ * markup refusal is retried once as plain text.
302
378
  */
303
379
  export async function editMaxMessage(messageId, text, opts = {}) {
304
380
  const token = resolveToken(opts);
305
381
  const api = new MaxApi({ token });
306
- await api.editMessage(messageId, {
307
- text: formatOutboundText(text, opts.format),
308
- format: opts.format ?? undefined,
309
- ...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
382
+ await withPlainTextFallback(text, opts, async (editOpts) => {
383
+ await api.editMessage(messageId, {
384
+ text: formatOutboundText(text, editOpts.format),
385
+ format: editOpts.format ?? undefined,
386
+ ...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
387
+ });
310
388
  });
311
389
  }
312
390
  /**
@@ -395,8 +473,13 @@ function isAttachmentNotReady(err) {
395
473
  /** Images and videos per message (docs «Медиафайлы»: up to 12 in total). */
396
474
  export const MAX_VISUAL_MEDIA_PER_MESSAGE = 12;
397
475
  const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
476
+ /** Readable label of a source for errors and logs (never the content). */
477
+ export function describeMaxMediaSource(source) {
478
+ return typeof source === 'string' ? source : `${source.fileName} (inline)`;
479
+ }
480
+ /** Any case: core's local-file loader would fetch such a URL itself, past the proxy. */
398
481
  function isRemoteUrl(source) {
399
- return source.startsWith('https://') || source.startsWith('http://');
482
+ return /^https?:\/\//i.test(source);
400
483
  }
401
484
  /** https link whose path looks like an image: MAX can fetch it itself (image.payload.url). */
402
485
  function isImageLink(source) {
@@ -409,7 +492,29 @@ function isImageLink(source) {
409
492
  return false;
410
493
  }
411
494
  }
495
+ /**
496
+ * An image link handed to MAX by URL must point to a public host: not a
497
+ * private, loopback, link-local or metadata address, also after DNS
498
+ * resolution. Anything else is downloaded through the guarded fetcher (which
499
+ * refuses such hosts as well) and uploaded.
500
+ */
501
+ async function isPublicImageLink(source) {
502
+ if (typeof source !== 'string' || !isImageLink(source))
503
+ return false;
504
+ try {
505
+ const { hostname } = new URL(source);
506
+ if (isBlockedHostnameOrIp(hostname))
507
+ return false;
508
+ await resolvePinnedHostnameWithPolicy(hostname);
509
+ return true;
510
+ }
511
+ catch {
512
+ return false;
513
+ }
514
+ }
412
515
  function mediaKind(source) {
516
+ if (typeof source !== 'string')
517
+ return detectMaxMediaType(source.fileName);
413
518
  if (!isRemoteUrl(source))
414
519
  return detectMaxMediaType(source);
415
520
  try {
@@ -419,33 +524,122 @@ function mediaKind(source) {
419
524
  return 'file';
420
525
  }
421
526
  }
422
- /** Upload a local path, or a remote URL through a temporary file. */
423
- async function uploadMaxAttachment(api, source, opts) {
424
- if (isRemoteUrl(source)) {
425
- return withRemoteMediaTempFile(source, opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES, async ({ path, contentType }) => {
426
- const type = detectMaxMediaType(path);
427
- const uploaded = await api.uploadMedia(type, path, contentType);
428
- return { type, payload: { token: uploaded.token } };
429
- });
527
+ /**
528
+ * A local media file through core's guarded loader (loadWebMediaRaw): only
529
+ * under the allowed roots, through the host reader when core passed one,
530
+ * capped at the media size limit. A path outside the roots fails with
531
+ * LocalMediaAccessError ("Local media path is not under an allowed
532
+ * directory") before any byte is read.
533
+ */
534
+ async function loadLocalMaxMedia(source, opts) {
535
+ const loaded = await loadWebMediaRaw(source, buildOutboundMediaLoadOptions({
536
+ maxBytes: opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
537
+ mediaAccess: opts.localMedia?.mediaAccess,
538
+ mediaLocalRoots: opts.localMedia?.mediaLocalRoots,
539
+ mediaReadFile: opts.localMedia?.mediaReadFile,
540
+ optimizeImages: false,
541
+ }));
542
+ return {
543
+ buffer: loaded.buffer,
544
+ contentType: loaded.contentType,
545
+ fileName: sanitizeMaxFileName(loaded.fileName, loaded.contentType),
546
+ };
547
+ }
548
+ /**
549
+ * Inline content within the media size limit, as an upload-ready file. The
550
+ * caller decodes it; the limit is checked again here for every path in.
551
+ */
552
+ function loadInlineMaxMedia(source, opts) {
553
+ const maxBytes = opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
554
+ if (source.buffer.byteLength > maxBytes) {
555
+ throw new Error(`Inline media ${source.fileName} is ${source.buffer.byteLength} bytes, over the ${maxBytes}-byte limit (mediaMaxMb)`);
430
556
  }
431
- const type = detectMaxMediaType(source);
432
- const uploaded = await api.uploadMedia(type, source);
433
- return { type, payload: { token: uploaded.token } };
557
+ return {
558
+ buffer: source.buffer,
559
+ contentType: source.contentType,
560
+ fileName: sanitizeMaxFileName(source.fileName, source.contentType),
561
+ };
562
+ }
563
+ /**
564
+ * Load a local path (guarded loader), a remote URL (SSRF-guarded download)
565
+ * or inline content (size-checked).
566
+ */
567
+ async function loadMaxMediaSource(api, source, opts) {
568
+ return typeof source !== 'string'
569
+ ? loadInlineMaxMedia(source, opts)
570
+ : isRemoteUrl(source)
571
+ ? await downloadMaxRemoteMedia(source, opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES, api.mediaProxyUrl())
572
+ : await loadLocalMaxMedia(source, opts);
573
+ }
574
+ /**
575
+ * Upload type by file name; a voice send also takes audio known only by its
576
+ * MIME type (.oga, audio/webm) as audio.
577
+ */
578
+ function maxUploadType(media, asVoice) {
579
+ const type = detectMaxMediaType(media.fileName);
580
+ if (asVoice && media.contentType?.toLowerCase().startsWith('audio/'))
581
+ return 'audio';
582
+ return type;
434
583
  }
435
584
  /**
436
- * One message with the given media. https image links go as image.payload.url
437
- * (no download/upload round trip); if MAX refuses the message, those links are
438
- * uploaded and the message is sent once more. MAX processes video/file uploads
439
- * asynchronously — sendMessage may answer attachment.not.ready for a few
440
- * seconds; retry the send (not the upload).
585
+ * Upload loaded media. MAX may refuse an audio upload (a format it does not
586
+ * take as audio): nothing was sent yet, so the same bytes go as a file.
587
+ */
588
+ async function uploadMaxAttachment(api, media, type) {
589
+ try {
590
+ const uploaded = await api.uploadMedia(type, media.buffer, media.contentType, media.fileName);
591
+ return { type, payload: { token: uploaded.token } };
592
+ }
593
+ catch (err) {
594
+ if (type !== 'audio' || !(err instanceof MaxApiError))
595
+ throw err;
596
+ getMaxLogger().warn(`[MAX] audio upload of ${media.fileName} refused (${err.code ?? err.status}); sending as a file`);
597
+ return uploadMaxAttachment(api, media, 'file');
598
+ }
599
+ }
600
+ /**
601
+ * MAX refused a message with an audio attachment (400, also an audio still
602
+ * not processed after the attachment.not.ready retries): nothing was created,
603
+ * so one resend with the audio as a file cannot duplicate.
604
+ */
605
+ function isMaxAudioRefusal(err) {
606
+ return err instanceof MaxApiError && err.status === 400 && !isChatNotFound(err);
607
+ }
608
+ /**
609
+ * One message with the given media. Image links to public https hosts go as
610
+ * image.payload.url (no download/upload round trip); if MAX refuses the
611
+ * message, those links are uploaded and the message is sent once more. MAX
612
+ * processes video/file uploads asynchronously — sendMessage may answer
613
+ * attachment.not.ready for a few seconds; retry the send (not the upload).
614
+ * Audio goes as MAX's audio attachment (shown as a voice message); if MAX
615
+ * refuses it, the audio is sent once more as a file, so the reply is not lost.
441
616
  */
442
617
  async function sendMaxAttachmentsMessage(api, to, caption, sources, opts) {
618
+ const links = await Promise.all(sources.map(isPublicImageLink));
619
+ // Loaded once per message: a resend reuses the bytes instead of a new read.
620
+ const loaded = new Map();
621
+ const asVoice = opts.asVoice === true && sources.length === 1;
622
+ let audioAsFile = false;
623
+ let hasAudio = false;
443
624
  const build = async (allowLinks) => {
444
625
  const attachments = [];
445
- for (const source of sources) {
446
- attachments.push(allowLinks && isImageLink(source)
447
- ? { type: 'image', payload: { url: source } }
448
- : await uploadMaxAttachment(api, source, opts));
626
+ hasAudio = false;
627
+ for (const [index, source] of sources.entries()) {
628
+ if (allowLinks && links[index]) {
629
+ attachments.push({ type: 'image', payload: { url: source } });
630
+ continue;
631
+ }
632
+ let media = loaded.get(index);
633
+ if (!media) {
634
+ media = loadMaxMediaSource(api, source, opts);
635
+ loaded.set(index, media);
636
+ }
637
+ const loadedMedia = await media;
638
+ const type = maxUploadType(loadedMedia, asVoice);
639
+ const attachment = await uploadMaxAttachment(api, loadedMedia, type === 'audio' && audioAsFile ? 'file' : type);
640
+ if (attachment.type === 'audio')
641
+ hasAudio = true;
642
+ attachments.push(attachment);
449
643
  }
450
644
  if (opts.buttons?.length) {
451
645
  attachments.push(buildInlineKeyboard(opts.buttons));
@@ -459,15 +653,25 @@ async function sendMaxAttachmentsMessage(api, to, caption, sources, opts) {
459
653
  body: () => build(allowLinks),
460
654
  retry: retryWhileAttachmentNotReady,
461
655
  });
462
- if (!sources.some(isImageLink))
463
- return send(false);
656
+ if (!links.some(Boolean)) {
657
+ try {
658
+ return await send(false);
659
+ }
660
+ catch (err) {
661
+ if (!hasAudio || !isMaxAudioRefusal(err))
662
+ throw err;
663
+ getMaxLogger().warn(`[MAX] message with audio refused (${err.code ?? 400}); sending the audio as a file`);
664
+ audioAsFile = true;
665
+ return send(false);
666
+ }
667
+ }
464
668
  try {
465
669
  return await send(true);
466
670
  }
467
671
  catch (err) {
468
672
  if (!(err instanceof MaxApiError))
469
673
  throw err;
470
- console.warn(`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`);
674
+ getMaxLogger().warn(`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`);
471
675
  return send(false);
472
676
  }
473
677
  }
@@ -475,7 +679,7 @@ async function sendMaxAttachmentsMessage(api, to, caption, sources, opts) {
475
679
  * Send a media message to MAX (with upload).
476
680
  * @param to Chat ID or user ID
477
681
  * @param caption Text caption
478
- * @param mediaPath Local file path or URL (https image links are sent by URL)
682
+ * @param mediaPath Local file path (under the allowed roots), URL (public https image links are sent by URL) or inline content
479
683
  * @param opts Send options
480
684
  */
481
685
  export async function sendMaxMediaMessage(to, caption, mediaPath, opts = {}) {
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Edit streaming (streamMode "partial"): the first partial reply is sent as a
3
3
  * message, later partials edit it (throttled), and the final answer replaces
4
- * the draft together with its inline keyboard.
4
+ * the draft together with its inline keyboard. The draft holds one MAX
5
+ * message: a longer final answer is split by the reply delivery, which puts
6
+ * the first chunk into the draft and sends the rest as new messages.
5
7
  */
6
- import { editMaxMessage, sendMaxMessage } from './send.js';
8
+ import { deleteMaxMessage, editMaxMessage, resolveMaxSendFlags, sendMaxMessage, } from './send.js';
7
9
  const DRAFT_THROTTLE_MS = 1200;
8
10
  const DRAFT_MAX_CHARS = 4000;
9
11
  const DRAFT_MIN_CHARS = 30; // Don't send until we have enough text
@@ -15,6 +17,18 @@ export function createMaxDraftStream(params) {
15
17
  let draftLastEditAt = 0;
16
18
  let draftTimer = null;
17
19
  let draftStopped = false;
20
+ let draftFinalized = false;
21
+ // Sends and edits run one at a time: a deferred update or the final never
22
+ // races the first send (which would leave a second, orphaned draft).
23
+ let inflight = Promise.resolve();
24
+ const stop = async () => {
25
+ if (draftTimer) {
26
+ clearTimeout(draftTimer);
27
+ draftTimer = null;
28
+ }
29
+ draftStopped = true;
30
+ await inflight;
31
+ };
18
32
  const draftUpdate = async (text) => {
19
33
  if (draftStopped || !text)
20
34
  return;
@@ -43,60 +57,88 @@ export function createMaxDraftStream(params) {
43
57
  }
44
58
  draftLastText = trimmed;
45
59
  draftLastEditAt = now;
46
- try {
47
- if (!draftMid) {
48
- // First chunk — send new message
49
- const res = await sendMaxMessage(chatId, trimmed, {
50
- token: account.token,
51
- replyToMessageId: params.replyToId,
52
- format: 'markdown',
53
- });
54
- draftMid = res.messageId || null;
55
- statusSink?.({ lastOutboundAt: Date.now() });
60
+ const run = inflight.then(async () => {
61
+ // Stopped meanwhile (the final answer took over).
62
+ if (draftStopped)
63
+ return;
64
+ try {
65
+ if (!draftMid) {
66
+ // First chunk — send new message
67
+ // The draft is the message the user is notified about: account defaults apply.
68
+ const res = await sendMaxMessage(chatId, trimmed, {
69
+ token: account.token,
70
+ replyToMessageId: params.replyToId,
71
+ format: 'markdown',
72
+ ...resolveMaxSendFlags(account.config),
73
+ });
74
+ draftMid = res.messageId || null;
75
+ statusSink?.({ lastOutboundAt: Date.now() });
76
+ }
77
+ else {
78
+ // Edit existing message
79
+ await editMaxMessage(draftMid, trimmed, {
80
+ token: account.token,
81
+ format: 'markdown',
82
+ });
83
+ }
56
84
  }
57
- else {
58
- // Edit existing message
59
- await editMaxMessage(draftMid, trimmed, {
60
- token: account.token,
61
- format: 'markdown',
62
- });
85
+ catch (err) {
86
+ draftStopped = true;
87
+ log?.debug?.(`[${account.accountId}] MAX draft stream failed: ${String(err)}`);
63
88
  }
64
- }
65
- catch (err) {
66
- draftStopped = true;
67
- log?.debug?.(`[${account.accountId}] MAX draft stream failed: ${String(err)}`);
68
- }
89
+ });
90
+ inflight = run;
91
+ await run;
69
92
  };
70
93
  return {
71
94
  get messageId() {
72
95
  return draftMid;
73
96
  },
97
+ get finalized() {
98
+ return draftFinalized;
99
+ },
74
100
  update: draftUpdate,
75
101
  finalize: async (finalText, buttons) => {
76
102
  // Final delivery replaces the draft message with final text. The
77
103
  // keyboard (presentation buttons) goes onto the same edit, otherwise
78
104
  // the final answer would lose its buttons.
79
- if (draftMid && (finalText !== draftLastText || buttons?.length)) {
80
- try {
81
- await editMaxMessage(draftMid, finalText, {
82
- token: account.token,
83
- format: 'markdown',
84
- buttons,
85
- });
86
- draftLastText = finalText;
87
- }
88
- catch {
89
- /* best effort */
90
- }
105
+ await stop();
106
+ if (!draftMid)
107
+ return false;
108
+ if (finalText === draftLastText && !buttons?.length) {
109
+ draftFinalized = true;
110
+ return true;
111
+ }
112
+ try {
113
+ await editMaxMessage(draftMid, finalText, {
114
+ token: account.token,
115
+ format: 'markdown',
116
+ buttons,
117
+ });
118
+ draftLastText = finalText;
119
+ draftFinalized = true;
120
+ return true;
121
+ }
122
+ catch (err) {
123
+ log?.warn(`[${account.accountId}] MAX draft final edit failed: ${String(err)}`);
124
+ return false;
91
125
  }
92
- draftStopped = true;
93
126
  },
94
- clear: async () => {
95
- if (draftTimer) {
96
- clearTimeout(draftTimer);
97
- draftTimer = null;
127
+ discard: async () => {
128
+ await stop();
129
+ if (!draftMid)
130
+ return true;
131
+ try {
132
+ await deleteMaxMessage(draftMid, { token: account.token });
133
+ draftMid = null;
134
+ return true;
135
+ }
136
+ catch (err) {
137
+ log?.warn(`[${account.accountId}] MAX draft delete failed: ${String(err)}`);
138
+ return false;
98
139
  }
99
- draftStopped = true;
100
140
  },
141
+ clear: stop,
142
+ settle: stop,
101
143
  };
102
144
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Hand-off between a journaled webhook update and core's own restart recovery.
3
+ *
4
+ * Core records a recovery claim when it adopts an agent turn and resumes that
5
+ * turn after a crash by itself. The journal row of the update must therefore
6
+ * be completed at adoption, not only when the dispatch returns: replaying it
7
+ * after a crash mid-turn would run the turn twice (SDK durable ingress rule:
8
+ * "mark the event complete at dispatch adoption"). The webhook queue and the
9
+ * polling loop run each journaled dispatch with its completion callback (for
10
+ * polling: remembering the dedupe key); the reply dispatch passes
11
+ * it on as the public `turnAdoptionLifecycle` reply option. Without `admission`
12
+ * or `ownerKey` core groups queued follow-ups exactly as without a lifecycle.
13
+ */
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
+ const adoption = new AsyncLocalStorage();
16
+ /** Run a dispatch whose agent turn, once adopted by core, calls `onAdopted`. */
17
+ export function runWithMaxTurnAdoption(onAdopted, dispatch) {
18
+ return adoption.run(onAdopted, dispatch);
19
+ }
20
+ /** Reply options fragment carrying the current update's adoption callback, if any. */
21
+ export function maxTurnAdoptionReplyOptions() {
22
+ const onAdopted = adoption.getStore();
23
+ return onAdopted ? { turnAdoptionLifecycle: { onAdopted } } : {};
24
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * MAX typing indicator ("typing_on") on the SDK typing lifecycle.
3
+ *
4
+ * MAX shows the indicator for a few seconds and has no explicit stop action,
5
+ * so a long turn needs periodic refreshes. The shared SDK keepalive owns the
6
+ * refresh loop, the failure circuit breaker and the safety TTL; core stops it
7
+ * when the run completes, fails or is aborted.
8
+ */
9
+ import { createTypingCallbacks } from 'openclaw/plugin-sdk/channel-outbound';
10
+ import { resolveMaxAccount } from './accounts.js';
11
+ import { MaxApi } from './api.js';
12
+ import { resolveMaxTarget } from './send.js';
13
+ /** Refresh period: shorter than the indicator lifetime on MAX clients. */
14
+ export const MAX_TYPING_KEEPALIVE_MS = 4_000;
15
+ /** Deadline for one typing request; a slow one is dropped, not retried. */
16
+ const MAX_TYPING_TIMEOUT_MS = 5_000;
17
+ /**
18
+ * One typing_on request. Best effort: a single attempt with a short deadline,
19
+ * since a retried indicator could land after the reply it announced. The
20
+ * actions endpoint is not a message send, so the per-chat send limiter does
21
+ * not apply; the keepalive period keeps it far below 2 requests per second.
22
+ */
23
+ export async function sendMaxTypingAction(api, chatId, signal) {
24
+ await api.sendAction(chatId, 'typing_on', {
25
+ retryAttempts: 0,
26
+ timeoutMs: MAX_TYPING_TIMEOUT_MS,
27
+ ...(signal ? { signal } : {}),
28
+ });
29
+ }
30
+ /** Typing callbacks for one inbound turn in `chatId`; failures are only logged. */
31
+ export function createMaxTypingCallbacks(params) {
32
+ return createTypingCallbacks({
33
+ start: () => sendMaxTypingAction(params.api, params.chatId),
34
+ onStartError: params.onError,
35
+ keepaliveIntervalMs: MAX_TYPING_KEEPALIVE_MS,
36
+ });
37
+ }
38
+ /**
39
+ * heartbeat.sendTyping / sendTypingGuarded: core wraps them in the same
40
+ * keepalive lifecycle (heartbeat runs; gateway runs and task progress use the
41
+ * guarded variant with a cancellation signal and a send-authorization check). Only chat targets can show typing (the actions endpoint takes
42
+ * a chat id); user:<id> targets and accounts without a token are skipped.
43
+ */
44
+ export async function sendMaxHeartbeatTyping(params) {
45
+ const account = resolveMaxAccount({ cfg: params.cfg, accountId: params.accountId });
46
+ if (!account.token)
47
+ return;
48
+ const api = new MaxApi({ token: account.token });
49
+ const target = await resolveMaxTarget(api, params.to);
50
+ if (!('chat_id' in target) || params.signal?.aborted)
51
+ return;
52
+ // Recheck right before the physical send: the owner may have ended meanwhile.
53
+ params.assertPlatformSendAuthorized?.();
54
+ await sendMaxTypingAction(api, target.chat_id, params.signal);
55
+ }