@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.
- 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.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 +986 -13
- package/package.json +25 -5
- 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.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/api.js
CHANGED
|
@@ -9,17 +9,16 @@
|
|
|
9
9
|
* (Минцифры) CA, which is not in the default Node trust store. Requests go
|
|
10
10
|
* through a dedicated undici dispatcher whose CA set = system roots + Russian
|
|
11
11
|
* Trusted Root/Sub CA. Trust is scoped to this client only, never process-wide.
|
|
12
|
+
* With an account proxy (httpProxy) the same CA set verifies MAX inside the
|
|
13
|
+
* CONNECT tunnel; the base URL and proxy come from network.ts.
|
|
12
14
|
*/
|
|
13
15
|
import { createHash, randomBytes } from 'node:crypto';
|
|
14
16
|
import * as tls from 'node:tls';
|
|
15
17
|
import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
|
|
16
|
-
import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
|
|
18
|
+
import { Agent, buildConnector, fetch as undiciFetch, ProxyAgent } from 'undici';
|
|
19
|
+
import { bypassesProxy, lookupMaxNetwork, MaxNetworkConfigError, scrubProxyCredentials, } from './network.js';
|
|
20
|
+
import { getMaxLogger } from './runtime.js';
|
|
17
21
|
import { RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA } from './russian-trusted-ca.js';
|
|
18
|
-
/**
|
|
19
|
-
* platform-api.max.ru is shut down on 2026-07-19; platform-api2.max.ru is the
|
|
20
|
-
* canonical endpoint since June 2026.
|
|
21
|
-
*/
|
|
22
|
-
const BASE_URL = 'https://platform-api2.max.ru';
|
|
23
22
|
/**
|
|
24
23
|
* Default per-request deadline. Raised from 10s after an intermittent in-process
|
|
25
24
|
* stall aborted sends at exactly 10s ("This operation was aborted") while the
|
|
@@ -40,12 +39,34 @@ export function setMaxFetchForTests(fetchImpl) {
|
|
|
40
39
|
testFetchOverride = fetchImpl;
|
|
41
40
|
}
|
|
42
41
|
let cachedDispatcher;
|
|
43
|
-
|
|
42
|
+
const proxyDispatchers = new Map();
|
|
43
|
+
/** CA set for MAX hosts: system roots (with NODE_EXTRA_CA_CERTS) + Russian Trusted CA. */
|
|
44
|
+
function maxCaList() {
|
|
45
|
+
const tlsWithCaList = tls;
|
|
46
|
+
// getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
|
|
47
|
+
const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
|
|
48
|
+
return [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Dispatcher for one MAX request: through the account proxy (one ProxyAgent
|
|
52
|
+
* per proxy URL; the MAX CA set goes to requestTls — the TLS session with MAX
|
|
53
|
+
* runs inside the tunnel), or direct. Loopback targets (a local test stand)
|
|
54
|
+
* never use the proxy: over plain http the proxy would see the bot token.
|
|
55
|
+
*/
|
|
56
|
+
export function getMaxDispatcher(url, proxyUrl) {
|
|
57
|
+
if (proxyUrl && !bypassesProxy(url)) {
|
|
58
|
+
let proxied = proxyDispatchers.get(proxyUrl);
|
|
59
|
+
if (!proxied) {
|
|
60
|
+
proxied = new ProxyAgent({ uri: proxyUrl, requestTls: { ca: maxCaList() } });
|
|
61
|
+
proxyDispatchers.set(proxyUrl, proxied);
|
|
62
|
+
}
|
|
63
|
+
return proxied;
|
|
64
|
+
}
|
|
65
|
+
return getDirectMaxDispatcher();
|
|
66
|
+
}
|
|
67
|
+
function getDirectMaxDispatcher() {
|
|
44
68
|
if (!cachedDispatcher) {
|
|
45
|
-
const
|
|
46
|
-
// getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
|
|
47
|
-
const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
|
|
48
|
-
const ca = [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
|
|
69
|
+
const ca = maxCaList();
|
|
49
70
|
// Wrap the TLS connector to time the DNS+TCP+TLS phase, scoped to this
|
|
50
71
|
// dispatcher only (never process-wide). Diagnosing the intermittent
|
|
51
72
|
// "This operation was aborted" needs to separate a connect stall from a
|
|
@@ -57,7 +78,7 @@ function getMaxDispatcher() {
|
|
|
57
78
|
const err = args[0];
|
|
58
79
|
const elapsedMs = Date.now() - startedAt;
|
|
59
80
|
if (err || elapsedMs > MAX_SLOW_CONNECT_MS) {
|
|
60
|
-
|
|
81
|
+
getMaxLogger().error(`[MAX API] connect ${options.hostname} ${err ? 'failed' : 'slow'} dns+tcp+tls=${elapsedMs}ms` +
|
|
61
82
|
(err ? ` (${err.name})` : ''));
|
|
62
83
|
}
|
|
63
84
|
callback(...args);
|
|
@@ -67,13 +88,19 @@ function getMaxDispatcher() {
|
|
|
67
88
|
}
|
|
68
89
|
return cachedDispatcher;
|
|
69
90
|
}
|
|
70
|
-
function maxFetch(url, init) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
async function maxFetch(url, init, proxyUrl) {
|
|
92
|
+
try {
|
|
93
|
+
if (testFetchOverride)
|
|
94
|
+
return await testFetchOverride(url, init);
|
|
95
|
+
return (await undiciFetch(url, {
|
|
96
|
+
...init,
|
|
97
|
+
dispatcher: getMaxDispatcher(url, proxyUrl),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// Proxy failures may quote the proxy URL; its credentials never leave here.
|
|
102
|
+
throw scrubProxyCredentials(err, proxyUrl);
|
|
103
|
+
}
|
|
77
104
|
}
|
|
78
105
|
function escapeMultipartHeaderValue(value) {
|
|
79
106
|
return value
|
|
@@ -144,6 +171,12 @@ export class MaxChatSendLimiter {
|
|
|
144
171
|
}
|
|
145
172
|
}
|
|
146
173
|
let sharedSendLimiter = new MaxChatSendLimiter();
|
|
174
|
+
/** The bot's own user id per token digest, remembered from any GET /me (the start probe). */
|
|
175
|
+
const botUserIds = new Map();
|
|
176
|
+
/** @internal Forget remembered bot ids (test isolation). */
|
|
177
|
+
export function resetMaxBotUserIdsForTests() {
|
|
178
|
+
botUserIds.clear();
|
|
179
|
+
}
|
|
147
180
|
/** @internal Fresh process-wide send limiter (test isolation). */
|
|
148
181
|
export function resetMaxSendLimiterForTests() {
|
|
149
182
|
sharedSendLimiter = new MaxChatSendLimiter();
|
|
@@ -193,6 +226,8 @@ export class MaxRequestTimeoutError extends Error {
|
|
|
193
226
|
* duplicate the message — only a definite 429 (never applied) is retried.
|
|
194
227
|
*/
|
|
195
228
|
function isRetryableError(err, idempotent) {
|
|
229
|
+
if (err instanceof MaxNetworkConfigError)
|
|
230
|
+
return false;
|
|
196
231
|
// A client-side timeout is safe to retry only for idempotent methods here; the
|
|
197
232
|
// send path handles its own single, duplicate-safe retry for POST /messages.
|
|
198
233
|
if (err instanceof MaxRequestTimeoutError)
|
|
@@ -215,6 +250,7 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']);
|
|
|
215
250
|
export class MaxApi {
|
|
216
251
|
token;
|
|
217
252
|
baseUrl;
|
|
253
|
+
explicitNetwork;
|
|
218
254
|
timeoutMs;
|
|
219
255
|
retryAttempts;
|
|
220
256
|
sendLimiter;
|
|
@@ -224,15 +260,30 @@ export class MaxApi {
|
|
|
224
260
|
this.token = opts.token;
|
|
225
261
|
this.sendLimiter = opts.sendLimiter === undefined ? sharedSendLimiter : opts.sendLimiter;
|
|
226
262
|
this.tokenKey = createHash('sha256').update(opts.token).digest('hex').slice(0, 12);
|
|
227
|
-
this.baseUrl = opts.baseUrl
|
|
263
|
+
this.baseUrl = opts.baseUrl?.replace(/\/+$/, '');
|
|
264
|
+
this.explicitNetwork = opts.network;
|
|
228
265
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
229
266
|
const envAttempts = Number(process.env.OPENCLAW_MAX_RETRY_ATTEMPTS);
|
|
230
267
|
this.retryAttempts =
|
|
231
268
|
opts.retryAttempts ?? (Number.isFinite(envAttempts) && envAttempts >= 0 ? envAttempts : 3);
|
|
232
269
|
}
|
|
233
270
|
// ── HTTP helpers ──
|
|
271
|
+
/**
|
|
272
|
+
* Current network settings, read per request so a config reload applies to
|
|
273
|
+
* long-lived clients too. Throws MaxNetworkConfigError for a bad config.
|
|
274
|
+
*/
|
|
275
|
+
network() {
|
|
276
|
+
const network = this.explicitNetwork ?? lookupMaxNetwork(this.token);
|
|
277
|
+
return this.baseUrl ? { ...network, apiBaseUrl: this.baseUrl } : network;
|
|
278
|
+
}
|
|
279
|
+
/** Proxy for media downloads of this account (runtime media fetch), if any. */
|
|
280
|
+
mediaProxyUrl() {
|
|
281
|
+
return this.network().proxyUrl;
|
|
282
|
+
}
|
|
234
283
|
async requestOnce(method, path, params, body, timeoutMs, externalSignal) {
|
|
235
|
-
const
|
|
284
|
+
const network = this.network();
|
|
285
|
+
// Appended, not resolved: a base URL may carry a path prefix.
|
|
286
|
+
const url = new URL(`${network.apiBaseUrl}${path}`);
|
|
236
287
|
if (params) {
|
|
237
288
|
for (const [k, v] of Object.entries(params)) {
|
|
238
289
|
if (v != null)
|
|
@@ -262,7 +313,7 @@ export class MaxApi {
|
|
|
262
313
|
},
|
|
263
314
|
body: body ? JSON.stringify(body) : undefined,
|
|
264
315
|
signal,
|
|
265
|
-
});
|
|
316
|
+
}, network.proxyUrl);
|
|
266
317
|
gotResponse = true;
|
|
267
318
|
const json = (await res.json().catch(() => null));
|
|
268
319
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -270,12 +321,12 @@ export class MaxApi {
|
|
|
270
321
|
// it is excluded here; every other call reaching the server slowly is a
|
|
271
322
|
// fingerprint that points away from a connect hang.
|
|
272
323
|
if (elapsedMs > MAX_SLOW_REQUEST_MS && path !== '/updates') {
|
|
273
|
-
|
|
324
|
+
getMaxLogger().error(`[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`);
|
|
274
325
|
}
|
|
275
326
|
if (!res.ok) {
|
|
276
327
|
// Never log the request body: it may carry the webhook secret (POST
|
|
277
328
|
// /subscriptions) or private message text. Log only method+path+status.
|
|
278
|
-
|
|
329
|
+
getMaxLogger().error(`[MAX API] ${method} ${path} → ${res.status}`);
|
|
279
330
|
const error = new MaxApiError(`MAX API ${method} ${path} → ${res.status}`, res.status, json);
|
|
280
331
|
const retryAfter = res.headers?.get?.('retry-after');
|
|
281
332
|
if (retryAfter) {
|
|
@@ -295,13 +346,13 @@ export class MaxApi {
|
|
|
295
346
|
// opaque "This operation was aborted" into a typed, loggable timeout
|
|
296
347
|
// that records whether a response had started.
|
|
297
348
|
const phase = gotResponse ? 'reading-body' : 'awaiting-response';
|
|
298
|
-
|
|
349
|
+
getMaxLogger().error(`[MAX API] ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`);
|
|
299
350
|
throw new MaxRequestTimeoutError(method, path, elapsedMs, phase);
|
|
300
351
|
}
|
|
301
352
|
if (!isAbort && !(err instanceof MaxApiError)) {
|
|
302
353
|
// Network-level failure (DNS/TCP/TLS/reset): record timing so a connect
|
|
303
354
|
// stall is distinguishable from a response-wait stall in the log.
|
|
304
|
-
|
|
355
|
+
getMaxLogger().error(`[MAX API] ${method} ${path} failed after ${elapsedMs}ms: ${err.name}`);
|
|
305
356
|
}
|
|
306
357
|
throw err;
|
|
307
358
|
}
|
|
@@ -326,7 +377,14 @@ export class MaxApi {
|
|
|
326
377
|
}
|
|
327
378
|
// ── Bot info ──
|
|
328
379
|
async getMe() {
|
|
329
|
-
|
|
380
|
+
const me = await this.request('GET', '/me');
|
|
381
|
+
if (typeof me?.user_id === 'number')
|
|
382
|
+
botUserIds.set(this.tokenKey, me.user_id);
|
|
383
|
+
return me;
|
|
384
|
+
}
|
|
385
|
+
/** The bot's own user id: remembered from a GET /me with this token, else fetched once. */
|
|
386
|
+
async getBotUserId() {
|
|
387
|
+
return botUserIds.get(this.tokenKey) ?? (await this.getMe()).user_id;
|
|
330
388
|
}
|
|
331
389
|
// ── Messages ──
|
|
332
390
|
async sendMessage(body, params) {
|
|
@@ -383,8 +441,8 @@ export class MaxApi {
|
|
|
383
441
|
return this.request('DELETE', `/chats/${chatId}/pin`);
|
|
384
442
|
}
|
|
385
443
|
// ── Chat actions ──
|
|
386
|
-
async sendAction(chatId, action) {
|
|
387
|
-
return this.request('POST', `/chats/${chatId}/actions`, undefined, { action });
|
|
444
|
+
async sendAction(chatId, action, opts) {
|
|
445
|
+
return this.request('POST', `/chats/${chatId}/actions`, undefined, { action }, opts?.timeoutMs, opts?.retryAttempts, opts?.signal);
|
|
388
446
|
}
|
|
389
447
|
// ── Callbacks ──
|
|
390
448
|
async answerCallback(callbackId, body) {
|
|
@@ -442,70 +500,28 @@ export class MaxApi {
|
|
|
442
500
|
return this.request('POST', `/uploads`, { type });
|
|
443
501
|
}
|
|
444
502
|
/**
|
|
445
|
-
* Upload media
|
|
503
|
+
* Upload media bytes to MAX.
|
|
446
504
|
* MAX upload requires multipart/form-data with field "data".
|
|
447
505
|
* Response contains a token (in photos/videos/etc) to use in attachments.
|
|
506
|
+
* Takes bytes only: local files are read by the callers through the host
|
|
507
|
+
* media access policy (allowed roots, host reader), never by path here.
|
|
448
508
|
*
|
|
449
509
|
* @param type Media type (image, video, audio, file)
|
|
450
|
-
* @param data File
|
|
451
|
-
* @param contentType MIME type (
|
|
510
|
+
* @param data File contents
|
|
511
|
+
* @param contentType MIME type (default application/octet-stream)
|
|
512
|
+
* @param fileName File name for the multipart part (default "file")
|
|
452
513
|
* @returns Upload result with token (for use in attachment payload)
|
|
453
514
|
*/
|
|
454
|
-
async uploadMedia(type, data, contentType) {
|
|
515
|
+
async uploadMedia(type, data, contentType, fileName = 'file') {
|
|
455
516
|
// Step 1: Get upload URL and attachment token.
|
|
456
517
|
// MAX returns the attachment token together with the upload URL. The
|
|
457
518
|
// following upload host may answer with XML like `<retval>1</retval>` and
|
|
458
519
|
// does not repeat that token.
|
|
459
520
|
const uploadInfo = await this.getUploadUrl(type);
|
|
460
521
|
const uploadUrl = uploadInfo.url;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
let fileName = 'file';
|
|
465
|
-
if (typeof data === 'string') {
|
|
466
|
-
// File path — read from disk
|
|
467
|
-
const fs = await import('fs/promises');
|
|
468
|
-
const path = await import('path');
|
|
469
|
-
fileBuffer = await fs.readFile(data);
|
|
470
|
-
fileName = path.basename(data);
|
|
471
|
-
// Auto-detect MIME type if not provided
|
|
472
|
-
if (!mimeType) {
|
|
473
|
-
const ext = data.split('.').pop()?.toLowerCase();
|
|
474
|
-
const mimeMap = {
|
|
475
|
-
jpg: 'image/jpeg',
|
|
476
|
-
jpeg: 'image/jpeg',
|
|
477
|
-
png: 'image/png',
|
|
478
|
-
gif: 'image/gif',
|
|
479
|
-
webp: 'image/webp',
|
|
480
|
-
heic: 'image/heic',
|
|
481
|
-
heif: 'image/heif',
|
|
482
|
-
tif: 'image/tiff',
|
|
483
|
-
tiff: 'image/tiff',
|
|
484
|
-
bmp: 'image/bmp',
|
|
485
|
-
mp4: 'video/mp4',
|
|
486
|
-
mov: 'video/quicktime',
|
|
487
|
-
avi: 'video/x-msvideo',
|
|
488
|
-
mkv: 'video/x-matroska',
|
|
489
|
-
webm: 'video/webm',
|
|
490
|
-
mp3: 'audio/mpeg',
|
|
491
|
-
wav: 'audio/wav',
|
|
492
|
-
ogg: 'audio/ogg',
|
|
493
|
-
m4a: 'audio/mp4',
|
|
494
|
-
aac: 'audio/aac',
|
|
495
|
-
flac: 'audio/flac',
|
|
496
|
-
opus: 'audio/opus',
|
|
497
|
-
pdf: 'application/pdf',
|
|
498
|
-
txt: 'text/plain',
|
|
499
|
-
};
|
|
500
|
-
mimeType = ext ? mimeMap[ext] : 'application/octet-stream';
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
// Already a buffer
|
|
505
|
-
fileBuffer = Buffer.from(data);
|
|
506
|
-
mimeType = mimeType ?? 'application/octet-stream';
|
|
507
|
-
}
|
|
508
|
-
// Step 3: POST file as multipart/form-data to upload URL. MAX upload hosts
|
|
522
|
+
const fileBuffer = Buffer.from(data);
|
|
523
|
+
const mimeType = contentType ?? 'application/octet-stream';
|
|
524
|
+
// Step 2: POST file as multipart/form-data to upload URL. MAX upload hosts
|
|
509
525
|
// reject Node's native FormData in some cases with 412, while curl-style
|
|
510
526
|
// multipart with a known Content-Length is accepted.
|
|
511
527
|
const multipart = buildMultipartFileBody('data', fileName, mimeType, fileBuffer);
|
|
@@ -516,11 +532,11 @@ export class MaxApi {
|
|
|
516
532
|
'Content-Length': String(multipart.body.byteLength),
|
|
517
533
|
},
|
|
518
534
|
body: multipart.body,
|
|
519
|
-
});
|
|
535
|
+
}, this.network().proxyUrl);
|
|
520
536
|
if (!uploadRes.ok) {
|
|
521
537
|
throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}`, uploadRes.status, await uploadRes.text().catch(() => null));
|
|
522
538
|
}
|
|
523
|
-
// Step
|
|
539
|
+
// Step 3: Parse response to extract token
|
|
524
540
|
// Image response: { photos: { "<id>": { token: "...", url: "..." } } }
|
|
525
541
|
// Video/audio/file may respond with a top-level token or a similar nested map
|
|
526
542
|
const result = (await uploadRes.json().catch(() => ({})));
|
package/dist/src/callbacks.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { resolveApprovalOverGateway } from 'openclaw/plugin-sdk/approval-gateway-runtime';
|
|
7
7
|
import { questionGatewayRuntime } from 'openclaw/plugin-sdk/question-gateway-runtime';
|
|
8
|
+
import { admitMaxGroupMessage } from './access-policy.js';
|
|
9
|
+
import { decodeMaxCommandMenuPayload } from './command-menu.js';
|
|
8
10
|
import { processIncomingMessage } from './inbound.js';
|
|
9
11
|
import { decodeMaxPresentationCallback } from './presentation.js';
|
|
10
12
|
/**
|
|
@@ -37,7 +39,17 @@ export async function processCallback(callback, message, userLocale, opts) {
|
|
|
37
39
|
// plain channelData.max.buttons payloads keep arriving as message text.
|
|
38
40
|
const presentationCallback = decodeMaxPresentationCallback(payload);
|
|
39
41
|
if (presentationCallback?.kind === 'approval' || presentationCallback?.kind === 'question') {
|
|
40
|
-
await resolveMaxRuntimeControlCallback(presentationCallback, callback, opts);
|
|
42
|
+
await resolveMaxRuntimeControlCallback(presentationCallback, callback, message, opts);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Command menu choices re-enter as that command's text; inbound checks the
|
|
46
|
+
// sender's command rights and whether core still offers the choice.
|
|
47
|
+
const menuCommand = decodeMaxCommandMenuPayload(payload);
|
|
48
|
+
if (menuCommand) {
|
|
49
|
+
const menuMessage = Object.assign(buildCallbackMessage(callback, message, menuCommand), {
|
|
50
|
+
__maxCommandMenu: true,
|
|
51
|
+
});
|
|
52
|
+
await processIncomingMessage(menuMessage, userLocale, opts);
|
|
41
53
|
return;
|
|
42
54
|
}
|
|
43
55
|
// Opaque callback data goes to the agent labelled, never as a slash command.
|
|
@@ -49,19 +61,27 @@ export async function processCallback(callback, message, userLocale, opts) {
|
|
|
49
61
|
/**
|
|
50
62
|
* Approvals and ask_user answers are operator actions: only senders listed
|
|
51
63
|
* explicitly in the account's allowFrom may press them (a wildcard is enough
|
|
52
|
-
* for questions, never for approvals).
|
|
64
|
+
* for questions, never for approvals). A wildcard answer in a group also
|
|
65
|
+
* needs the group policy and its sender allowlist; with the chat unknown
|
|
66
|
+
* (the keyboard message is gone) the wildcard is not enough.
|
|
53
67
|
*/
|
|
54
|
-
function isMaxRuntimeControlSender(
|
|
68
|
+
function isMaxRuntimeControlSender(opts, message, senderId, kind) {
|
|
69
|
+
const { account, config } = opts;
|
|
55
70
|
const allowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry).trim().replace(/^max:/i, ''));
|
|
56
71
|
if (allowFrom.includes(senderId))
|
|
57
72
|
return true;
|
|
58
|
-
|
|
73
|
+
if (kind !== 'question' || !allowFrom.includes('*') || !message)
|
|
74
|
+
return false;
|
|
75
|
+
const { chat_id: chatId, chat_type: chatType } = message.recipient;
|
|
76
|
+
if (chatType !== 'chat' && chatType !== 'channel')
|
|
77
|
+
return true;
|
|
78
|
+
return admitMaxGroupMessage(account, config, chatId, senderId).admitted;
|
|
59
79
|
}
|
|
60
|
-
async function resolveMaxRuntimeControlCallback(action, callback, opts) {
|
|
80
|
+
async function resolveMaxRuntimeControlCallback(action, callback, message, opts) {
|
|
61
81
|
const { account, config, log } = opts;
|
|
62
82
|
const senderId = String(callback.user.user_id);
|
|
63
83
|
let notification;
|
|
64
|
-
if (!isMaxRuntimeControlSender(
|
|
84
|
+
if (!isMaxRuntimeControlSender(opts, message, senderId, action.kind)) {
|
|
65
85
|
log?.warn(`[${account.accountId}] MAX ${action.kind} button pressed by unauthorized sender ${senderId}`);
|
|
66
86
|
notification = 'You are not allowed to answer this.';
|
|
67
87
|
}
|
|
@@ -87,7 +107,7 @@ async function resolveMaxRuntimeControlCallback(action, callback, opts) {
|
|
|
87
107
|
questionId: action.questionId,
|
|
88
108
|
optionValue: action.optionValue,
|
|
89
109
|
senderId,
|
|
90
|
-
authorize: () => isMaxRuntimeControlSender(
|
|
110
|
+
authorize: () => isMaxRuntimeControlSender(opts, message, senderId, 'question'),
|
|
91
111
|
});
|
|
92
112
|
notification =
|
|
93
113
|
result.status === 'answered'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent prompt hints of the MAX channel: stickers, location, contact, pin,
|
|
3
|
-
* button types
|
|
3
|
+
* button types, presentation, voice, inline files, silent sends, local file
|
|
4
|
+
* roots and action scope for the message tool.
|
|
4
5
|
*/
|
|
5
6
|
/** message tool hints for MAX-specific actions. */
|
|
6
7
|
export const maxAgentPromptAdapter = {
|
|
@@ -8,31 +9,19 @@ export const maxAgentPromptAdapter = {
|
|
|
8
9
|
// Compact sticker emoji map: top 50 emojis → sticker codes
|
|
9
10
|
// Codes are hex IDs derived from listmax.ru external_id: parseInt(extId).toString(16)
|
|
10
11
|
const emojiMap = '😂:109550b5 😊:109971b5 😍:10931eb5 🥰:10931eb5 😢:109330b5 😭:109330b5 😡:10941fb5 😱:109302b5 🤔:109308b5 👍:109368b5 👎:109323b5 ❤️:10931eb5 🔥:b4867ebb 💪:c1254bbb 🎉:10933cb5 😘:10931eb5 🤗:109b94b5 😎:10931db5 🙄:c14211bb 😴:10936eb5 😤:10941fb5 🤮:6b8bb 🤯:109302b5 😳:109302b5 🥳:10933cb5 💀:b4863ebb 🙈:b4850cbb 😏:11e4c60bb 😅:109550b5 🤣:109550b5 😋:109d2db5 😜:455b5 🤷:10d5cf5bb 😫:10936eb5 😩:10997db5 🥺:109356b5 😌:14aae3bb 😒:109323b5 🤪:455b5 😇:11e4dedbb 🙏:50cb5 💔:10997db5 👀:b48534bb ✨:11e43b2bb 😈:10941fb5 🤝:109368b5 🤦:502b5 😬:5dab4b5 🤩:5dabfb5 😶:2ae2b5';
|
|
11
|
-
// Also try to load full sticker-emoji-map.json for extended catalog
|
|
12
|
-
let extendedHint = '';
|
|
13
|
-
try {
|
|
14
|
-
const fs = require('fs');
|
|
15
|
-
const path = require('path');
|
|
16
|
-
const candidates = [
|
|
17
|
-
path.join(__dirname, '..', 'sticker-emoji-map.json'),
|
|
18
|
-
path.join(process.cwd(), 'projects', 'openclaw-max', 'sticker-emoji-map.json'),
|
|
19
|
-
];
|
|
20
|
-
for (const p of candidates) {
|
|
21
|
-
if (fs.existsSync(p)) {
|
|
22
|
-
extendedHint = ' Full emoji→sticker map available at: ' + p;
|
|
23
|
-
break;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
catch { }
|
|
28
12
|
return [
|
|
29
13
|
'- MAX stickers: use `message(action="sticker", target="CHAT_ID", stickerId="CODE")`. Pick a sticker code matching the mood from the emoji map below. Each entry is emoji:hexCode.',
|
|
30
|
-
`- Sticker emoji map: ${emojiMap}
|
|
14
|
+
`- Sticker emoji map: ${emojiMap}`,
|
|
31
15
|
'- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
|
|
32
16
|
'- MAX contact: use `message(action="sendAttachment", target="CHAT_ID", type="contact", contactName="Name", vcfPhone="+70001234567")` to send a native contact card.',
|
|
33
17
|
'- MAX pin: use `message(action="pin", target="CHAT_ID", messageId="MID")` to pin a message; `message(action="unpin", target="CHAT_ID")` to unpin.',
|
|
34
18
|
'- MAX buttons support types: callback (default), link, message (sends the button text as a user message — great for suggested replies), clipboard (copies payload), open_app (webApp = public name of the bot wired to the mini app), request_contact, request_geo_location. Pass via buttons=[[{"text":"...","type":"message"}]].',
|
|
35
19
|
'- MAX rich messages: prefer `presentation` (title/tone, text, context, divider, table/chart as monospace text, buttons and select as an inline keyboard); `pin=true` pins the sent message.',
|
|
20
|
+
'- MAX voice: `asVoice=true` on `send`/`sendAttachment` with an audio file (mp3, m4a, wav, ogg, opus) sends it as a MAX audio message instead of a file.',
|
|
21
|
+
'- MAX files without a path: `sendAttachment` takes `buffer` (base64 or a data URL) with `filename` and `contentType`.',
|
|
22
|
+
'- MAX quiet sends: `silent=true` sends without a notification (MAX channels always notify).',
|
|
23
|
+
'- MAX local files: only files under the media roots OpenClaw allows for this agent (its workspace and media directories) can be sent; a path outside them is refused — save the file there first.',
|
|
24
|
+
"- MAX action scope: `send`, `sendAttachment`, `sticker`, `edit`, `delete`, `pin` and `unpin` work in the current chat and in chats the channel access policy admits, or on the owner's request (channels.max.actionScope). A refusal is a tool error: report it, do not retry in another chat.",
|
|
36
25
|
];
|
|
37
26
|
},
|
|
38
27
|
};
|
|
@@ -3,11 +3,29 @@
|
|
|
3
3
|
* and resolution, enable/delete, allowFrom formatting, `openclaw channels add`.
|
|
4
4
|
*/
|
|
5
5
|
import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from 'openclaw/plugin-sdk/core';
|
|
6
|
-
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
6
|
+
import { isMaxAccountConfigured, listMaxAccountIds, readMaxAccount, resolveMaxAccount, } from './accounts.js';
|
|
7
|
+
import { resolveMaxTransport } from './monitor-types.js';
|
|
7
8
|
/** Accounts under channels.max (top level = default account, plus channels.max.accounts). */
|
|
8
9
|
export const maxConfigAdapter = {
|
|
9
10
|
listAccountIds: (cfg) => listMaxAccountIds(cfg),
|
|
10
11
|
resolveAccount: (cfg, accountId) => resolveMaxAccount({ cfg, accountId }),
|
|
12
|
+
// Read-only status (openclaw status / channels status, doctor): where the
|
|
13
|
+
// token comes from and whether it is usable in this command path, never
|
|
14
|
+
// the token itself. A SecretRef the command cannot read is
|
|
15
|
+
// configured_unavailable, not "not configured".
|
|
16
|
+
inspectAccount: (cfg, accountId) => {
|
|
17
|
+
const account = readMaxAccount({ cfg, accountId });
|
|
18
|
+
return {
|
|
19
|
+
accountId: account.accountId,
|
|
20
|
+
...(account.name ? { name: account.name } : {}),
|
|
21
|
+
enabled: account.enabled,
|
|
22
|
+
configured: isMaxAccountConfigured(account),
|
|
23
|
+
tokenSource: account.tokenSource,
|
|
24
|
+
tokenStatus: account.tokenStatus,
|
|
25
|
+
mode: resolveMaxTransport(account.config),
|
|
26
|
+
...(account.stateReason ? { stateReason: account.stateReason } : {}),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
11
29
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
12
30
|
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
|
|
13
31
|
cfg,
|
|
@@ -22,13 +40,17 @@ export const maxConfigAdapter = {
|
|
|
22
40
|
accountId,
|
|
23
41
|
clearBaseFields: ['botToken', 'tokenFile', 'name'],
|
|
24
42
|
}),
|
|
25
|
-
|
|
43
|
+
// An unresolved SecretRef token counts as configured: startAccount then
|
|
44
|
+
// fails with its config path instead of the account being silently skipped.
|
|
45
|
+
isConfigured: (account) => isMaxAccountConfigured(account),
|
|
26
46
|
describeAccount: (account) => ({
|
|
27
47
|
accountId: account.accountId,
|
|
28
48
|
name: account.name,
|
|
29
49
|
enabled: account.enabled,
|
|
30
|
-
configured:
|
|
50
|
+
configured: isMaxAccountConfigured(account),
|
|
31
51
|
tokenSource: account.tokenSource,
|
|
52
|
+
tokenStatus: account.tokenStatus,
|
|
53
|
+
...(account.stateReason ? { stateReason: account.stateReason } : {}),
|
|
32
54
|
}),
|
|
33
55
|
resolveAllowFrom: ({ cfg, accountId }) => (resolveMaxAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
|
|
34
56
|
formatAllowFrom: ({ allowFrom }) => allowFrom
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* monitor) and logout, status snapshots, probes and group audits (health).
|
|
4
4
|
*/
|
|
5
5
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
|
|
6
|
+
import { describeMaxInheritedOpenAccess } from './access-policy.js';
|
|
7
|
+
import { isMaxAccountConfigured, MaxSecretConfigError, resolveMaxAccountNetwork, } from './accounts.js';
|
|
6
8
|
import { MaxApi } from './api.js';
|
|
7
9
|
import { startMaxPolling } from './monitor.js';
|
|
10
|
+
import { DEFAULT_MAX_API_BASE_URL, redactProxyUrl } from './network.js';
|
|
8
11
|
import { writeMaxConfig } from './runtime.js';
|
|
9
12
|
/** Runtime snapshots, GET /me probe and a membership audit of configured groups. */
|
|
10
13
|
export const maxStatusAdapter = {
|
|
@@ -23,6 +26,7 @@ export const maxStatusAdapter = {
|
|
|
23
26
|
// compares them with the live runtime and treats a missing value as stale.
|
|
24
27
|
...(snapshot.lifecycle !== undefined ? { lifecycle: snapshot.lifecycle } : {}),
|
|
25
28
|
...(typeof snapshot.connected === 'boolean' ? { connected: snapshot.connected } : {}),
|
|
29
|
+
...(snapshot.mode ? { mode: snapshot.mode } : {}),
|
|
26
30
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
27
31
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
28
32
|
lastError: snapshot.lastError ?? null,
|
|
@@ -44,11 +48,15 @@ export const maxStatusAdapter = {
|
|
|
44
48
|
accountId: account.accountId,
|
|
45
49
|
name: account.name,
|
|
46
50
|
enabled: account.enabled,
|
|
47
|
-
configured:
|
|
51
|
+
configured: isMaxAccountConfigured(account),
|
|
48
52
|
tokenSource: account.tokenSource,
|
|
53
|
+
tokenStatus: account.tokenStatus,
|
|
54
|
+
...(account.stateReason ? { stateReason: account.stateReason } : {}),
|
|
49
55
|
running: runtime?.running ?? false,
|
|
50
56
|
...(runtime?.lifecycle !== undefined ? { lifecycle: runtime.lifecycle } : {}),
|
|
51
57
|
...(typeof runtime?.connected === 'boolean' ? { connected: runtime.connected } : {}),
|
|
58
|
+
// Transport the running task reported ("webhook" or "polling").
|
|
59
|
+
...(runtime?.mode ? { mode: runtime.mode } : {}),
|
|
52
60
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
53
61
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
54
62
|
lastError: runtime?.lastError ?? null,
|
|
@@ -138,7 +146,9 @@ export const maxStatusAdapter = {
|
|
|
138
146
|
channel: 'max',
|
|
139
147
|
accountId: snapshot.accountId,
|
|
140
148
|
kind: 'config',
|
|
141
|
-
message:
|
|
149
|
+
message: snapshot.stateReason
|
|
150
|
+
? `MAX bot token not configured: ${snapshot.stateReason}`
|
|
151
|
+
: 'MAX bot token not configured',
|
|
142
152
|
fix: 'Set channels.max.botToken or MAX_BOT_TOKEN env var',
|
|
143
153
|
});
|
|
144
154
|
}
|
|
@@ -150,12 +160,25 @@ export const maxStatusAdapter = {
|
|
|
150
160
|
export const maxGatewayAdapter = {
|
|
151
161
|
startAccount: async (ctx) => {
|
|
152
162
|
const account = ctx.account;
|
|
163
|
+
// Path and ref only; the gateway normally isolates such an account itself.
|
|
164
|
+
if (account.secretErrors?.botToken) {
|
|
165
|
+
throw new MaxSecretConfigError(account.secretErrors.botToken);
|
|
166
|
+
}
|
|
167
|
+
// A named account open only through channel-level (or default) policies.
|
|
168
|
+
const inheritedOpen = describeMaxInheritedOpenAccess(ctx.cfg, account);
|
|
169
|
+
if (inheritedOpen)
|
|
170
|
+
ctx.log?.warn(`[${account.accountId}] ${inheritedOpen}`);
|
|
153
171
|
const token = account.token.trim();
|
|
172
|
+
// A bad apiBaseUrl/httpProxy stops the account with a clear error rather
|
|
173
|
+
// than letting it connect directly or to the wrong host.
|
|
174
|
+
const network = resolveMaxAccountNetwork(account);
|
|
175
|
+
const route = (network.apiBaseUrl !== DEFAULT_MAX_API_BASE_URL ? ` api=${network.apiBaseUrl}` : '') +
|
|
176
|
+
(network.proxyUrl ? ` proxy=${redactProxyUrl(network.proxyUrl)}` : '');
|
|
154
177
|
let botLabel = '';
|
|
155
178
|
let botUserId;
|
|
156
179
|
let botUsername;
|
|
157
180
|
try {
|
|
158
|
-
const probeApi = new MaxApi({ token, timeoutMs: 3000 });
|
|
181
|
+
const probeApi = new MaxApi({ token, timeoutMs: 3000, network });
|
|
159
182
|
const me = await probeApi.getMe();
|
|
160
183
|
if (me.username) {
|
|
161
184
|
botLabel = ` (@${me.username})`;
|
|
@@ -166,8 +189,8 @@ export const maxGatewayAdapter = {
|
|
|
166
189
|
catch {
|
|
167
190
|
// probe failed, continue anyway
|
|
168
191
|
}
|
|
169
|
-
ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}`);
|
|
170
|
-
const api = new MaxApi({ token });
|
|
192
|
+
ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}${route}`);
|
|
193
|
+
const api = new MaxApi({ token, network });
|
|
171
194
|
// Register bot commands if configured
|
|
172
195
|
const commands = ctx.cfg.channels?.max?.commands;
|
|
173
196
|
if (commands?.length) {
|