@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/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
- function getMaxDispatcher() {
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 tlsWithCaList = tls;
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
- console.error(`[MAX API] connect ${options.hostname} ${err ? 'failed' : 'slow'} dns+tcp+tls=${elapsedMs}ms` +
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
- if (testFetchOverride)
72
- return testFetchOverride(url, init);
73
- return undiciFetch(url, {
74
- ...init,
75
- dispatcher: getMaxDispatcher(),
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 ?? BASE_URL;
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 url = new URL(path, this.baseUrl);
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
- console.error(`[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`);
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
- console.error(`[MAX API] ${method} ${path} → ${res.status}`);
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
- console.error(`[MAX API] ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`);
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
- console.error(`[MAX API] ${method} ${path} failed after ${elapsedMs}ms: ${err.name}`);
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
- return this.request('GET', '/me');
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 file to MAX.
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 path (string) or buffer (Buffer/Uint8Array)
451
- * @param contentType MIME type (optional, auto-detected for common types)
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
- // Step 2: Load file if data is a path
462
- let fileBuffer;
463
- let mimeType = contentType;
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 4: Parse response to extract token
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(() => ({})));
@@ -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(account, senderId, kind) {
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
- return kind === 'question' && allowFrom.includes('*');
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(account, senderId, action.kind)) {
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(account, senderId, 'question'),
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 and presentation for the message tool.
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}${extendedHint}`,
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
- isConfigured: (account) => Boolean(account.token?.trim()),
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: Boolean(account.token?.trim()),
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: Boolean(account.token?.trim()),
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: 'MAX bot token not configured',
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) {