@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.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- 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 {
|
|
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
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
/**
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
*
|
|
437
|
-
*
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
:
|
|
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 (!
|
|
463
|
-
|
|
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
|
-
|
|
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
|
|
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 = {}) {
|
package/dist/src/stream-draft.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|