@aspalagin/openclaw-max 0.7.3 โ†’ 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
package/src/api.ts CHANGED
@@ -9,14 +9,24 @@
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
 
14
16
  import { createHash, randomBytes } from 'node:crypto';
15
17
  import * as tls from 'node:tls';
16
18
 
17
19
  import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
18
- import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
19
-
20
+ import { Agent, buildConnector, fetch as undiciFetch, ProxyAgent } from 'undici';
21
+
22
+ import {
23
+ bypassesProxy,
24
+ lookupMaxNetwork,
25
+ type MaxNetwork,
26
+ MaxNetworkConfigError,
27
+ scrubProxyCredentials,
28
+ } from './network.js';
29
+ import { getMaxLogger } from './runtime.js';
20
30
  import { RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA } from './russian-trusted-ca.js';
21
31
  import type {
22
32
  MaxBotCommand,
@@ -65,12 +75,6 @@ export type {
65
75
  MaxVideoInfo,
66
76
  } from './types.js';
67
77
 
68
- /**
69
- * platform-api.max.ru is shut down on 2026-07-19; platform-api2.max.ru is the
70
- * canonical endpoint since June 2026.
71
- */
72
- const BASE_URL = 'https://platform-api2.max.ru';
73
-
74
78
  /**
75
79
  * Default per-request deadline. Raised from 10s after an intermittent in-process
76
80
  * stall aborted sends at exactly 10s ("This operation was aborted") while the
@@ -109,15 +113,39 @@ export function setMaxFetchForTests(fetchImpl: FetchLike | undefined): void {
109
113
  }
110
114
 
111
115
  let cachedDispatcher: Agent | undefined;
116
+ const proxyDispatchers = new Map<string, ProxyAgent>();
117
+
118
+ /** CA set for MAX hosts: system roots (with NODE_EXTRA_CA_CERTS) + Russian Trusted CA. */
119
+ function maxCaList(): string[] {
120
+ const tlsWithCaList = tls as unknown as {
121
+ getCACertificates?: (type: string) => readonly string[];
122
+ };
123
+ // getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
124
+ const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
125
+ return [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
126
+ }
127
+
128
+ /**
129
+ * Dispatcher for one MAX request: through the account proxy (one ProxyAgent
130
+ * per proxy URL; the MAX CA set goes to requestTls โ€” the TLS session with MAX
131
+ * runs inside the tunnel), or direct. Loopback targets (a local test stand)
132
+ * never use the proxy: over plain http the proxy would see the bot token.
133
+ */
134
+ export function getMaxDispatcher(url: string, proxyUrl?: string): Agent | ProxyAgent {
135
+ if (proxyUrl && !bypassesProxy(url)) {
136
+ let proxied = proxyDispatchers.get(proxyUrl);
137
+ if (!proxied) {
138
+ proxied = new ProxyAgent({ uri: proxyUrl, requestTls: { ca: maxCaList() } });
139
+ proxyDispatchers.set(proxyUrl, proxied);
140
+ }
141
+ return proxied;
142
+ }
143
+ return getDirectMaxDispatcher();
144
+ }
112
145
 
113
- function getMaxDispatcher(): Agent {
146
+ function getDirectMaxDispatcher(): Agent {
114
147
  if (!cachedDispatcher) {
115
- const tlsWithCaList = tls as unknown as {
116
- getCACertificates?: (type: string) => readonly string[];
117
- };
118
- // getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
119
- const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
120
- const ca = [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
148
+ const ca = maxCaList();
121
149
  // Wrap the TLS connector to time the DNS+TCP+TLS phase, scoped to this
122
150
  // dispatcher only (never process-wide). Diagnosing the intermittent
123
151
  // "This operation was aborted" needs to separate a connect stall from a
@@ -130,7 +158,7 @@ function getMaxDispatcher(): Agent {
130
158
  const err = args[0];
131
159
  const elapsedMs = Date.now() - startedAt;
132
160
  if (err || elapsedMs > MAX_SLOW_CONNECT_MS) {
133
- console.error(
161
+ getMaxLogger().error(
134
162
  `[MAX API] connect ${options.hostname} ${err ? 'failed' : 'slow'} dns+tcp+tls=${elapsedMs}ms` +
135
163
  (err ? ` (${err.name})` : ''),
136
164
  );
@@ -143,12 +171,21 @@ function getMaxDispatcher(): Agent {
143
171
  return cachedDispatcher;
144
172
  }
145
173
 
146
- function maxFetch(url: string, init: Record<string, unknown>): ReturnType<FetchLike> {
147
- if (testFetchOverride) return testFetchOverride(url, init);
148
- return undiciFetch(url, {
149
- ...init,
150
- dispatcher: getMaxDispatcher(),
151
- } as Parameters<typeof undiciFetch>[1]) as unknown as ReturnType<FetchLike>;
174
+ async function maxFetch(
175
+ url: string,
176
+ init: Record<string, unknown>,
177
+ proxyUrl?: string,
178
+ ): ReturnType<FetchLike> {
179
+ try {
180
+ if (testFetchOverride) return await testFetchOverride(url, init);
181
+ return (await undiciFetch(url, {
182
+ ...init,
183
+ dispatcher: getMaxDispatcher(url, proxyUrl),
184
+ } as Parameters<typeof undiciFetch>[1])) as unknown as Awaited<ReturnType<FetchLike>>;
185
+ } catch (err) {
186
+ // Proxy failures may quote the proxy URL; its credentials never leave here.
187
+ throw scrubProxyCredentials(err, proxyUrl);
188
+ }
152
189
  }
153
190
 
154
191
  function escapeMultipartHeaderValue(value: string): string {
@@ -184,7 +221,13 @@ function buildMultipartFileBody(
184
221
 
185
222
  export interface MaxApiOptions {
186
223
  token: string;
224
+ /** Overrides the API base URL of the network settings (tests). */
187
225
  baseUrl?: string;
226
+ /**
227
+ * Network settings for this client; default: those bound to the token by
228
+ * resolveMaxAccount (network.ts), else the public endpoint, direct.
229
+ */
230
+ network?: MaxNetwork;
188
231
  timeoutMs?: number;
189
232
  /** Retry attempts for transient errors (429 / 502-504 / network). 0 disables. Default 3. */
190
233
  retryAttempts?: number;
@@ -244,6 +287,14 @@ export class MaxChatSendLimiter {
244
287
 
245
288
  let sharedSendLimiter = new MaxChatSendLimiter();
246
289
 
290
+ /** The bot's own user id per token digest, remembered from any GET /me (the start probe). */
291
+ const botUserIds = new Map<string, number>();
292
+
293
+ /** @internal Forget remembered bot ids (test isolation). */
294
+ export function resetMaxBotUserIdsForTests(): void {
295
+ botUserIds.clear();
296
+ }
297
+
247
298
  /** @internal Fresh process-wide send limiter (test isolation). */
248
299
  export function resetMaxSendLimiterForTests(): void {
249
300
  sharedSendLimiter = new MaxChatSendLimiter();
@@ -293,6 +344,7 @@ export class MaxRequestTimeoutError extends Error {
293
344
  * duplicate the message โ€” only a definite 429 (never applied) is retried.
294
345
  */
295
346
  function isRetryableError(err: unknown, idempotent: boolean): boolean {
347
+ if (err instanceof MaxNetworkConfigError) return false;
296
348
  // A client-side timeout is safe to retry only for idempotent methods here; the
297
349
  // send path handles its own single, duplicate-safe retry for POST /messages.
298
350
  if (err instanceof MaxRequestTimeoutError) return idempotent;
@@ -312,7 +364,8 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']);
312
364
 
313
365
  export class MaxApi {
314
366
  private token: string;
315
- private baseUrl: string;
367
+ private baseUrl: string | undefined;
368
+ private explicitNetwork: MaxNetwork | undefined;
316
369
  private timeoutMs: number;
317
370
  private retryAttempts: number;
318
371
  private sendLimiter: MaxChatSendLimiter | null;
@@ -323,7 +376,8 @@ export class MaxApi {
323
376
  this.token = opts.token;
324
377
  this.sendLimiter = opts.sendLimiter === undefined ? sharedSendLimiter : opts.sendLimiter;
325
378
  this.tokenKey = createHash('sha256').update(opts.token).digest('hex').slice(0, 12);
326
- this.baseUrl = opts.baseUrl ?? BASE_URL;
379
+ this.baseUrl = opts.baseUrl?.replace(/\/+$/, '');
380
+ this.explicitNetwork = opts.network;
327
381
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
328
382
  const envAttempts = Number(process.env.OPENCLAW_MAX_RETRY_ATTEMPTS);
329
383
  this.retryAttempts =
@@ -332,6 +386,20 @@ export class MaxApi {
332
386
 
333
387
  // โ”€โ”€ HTTP helpers โ”€โ”€
334
388
 
389
+ /**
390
+ * Current network settings, read per request so a config reload applies to
391
+ * long-lived clients too. Throws MaxNetworkConfigError for a bad config.
392
+ */
393
+ private network(): MaxNetwork {
394
+ const network = this.explicitNetwork ?? lookupMaxNetwork(this.token);
395
+ return this.baseUrl ? { ...network, apiBaseUrl: this.baseUrl } : network;
396
+ }
397
+
398
+ /** Proxy for media downloads of this account (runtime media fetch), if any. */
399
+ mediaProxyUrl(): string | undefined {
400
+ return this.network().proxyUrl;
401
+ }
402
+
335
403
  private async requestOnce<T>(
336
404
  method: string,
337
405
  path: string,
@@ -340,7 +408,9 @@ export class MaxApi {
340
408
  timeoutMs?: number,
341
409
  externalSignal?: AbortSignal,
342
410
  ): Promise<T> {
343
- const url = new URL(path, this.baseUrl);
411
+ const network = this.network();
412
+ // Appended, not resolved: a base URL may carry a path prefix.
413
+ const url = new URL(`${network.apiBaseUrl}${path}`);
344
414
  if (params) {
345
415
  for (const [k, v] of Object.entries(params)) {
346
416
  if (v != null) url.searchParams.set(k, String(v));
@@ -363,15 +433,19 @@ export class MaxApi {
363
433
  : controller.signal;
364
434
 
365
435
  try {
366
- const res = await maxFetch(url.toString(), {
367
- method,
368
- headers: {
369
- Authorization: this.token,
370
- ...(body ? { 'Content-Type': 'application/json' } : {}),
436
+ const res = await maxFetch(
437
+ url.toString(),
438
+ {
439
+ method,
440
+ headers: {
441
+ Authorization: this.token,
442
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
443
+ },
444
+ body: body ? JSON.stringify(body) : undefined,
445
+ signal,
371
446
  },
372
- body: body ? JSON.stringify(body) : undefined,
373
- signal,
374
- });
447
+ network.proxyUrl,
448
+ );
375
449
  gotResponse = true;
376
450
 
377
451
  const json = (await res.json().catch(() => null)) as T;
@@ -381,13 +455,15 @@ export class MaxApi {
381
455
  // it is excluded here; every other call reaching the server slowly is a
382
456
  // fingerprint that points away from a connect hang.
383
457
  if (elapsedMs > MAX_SLOW_REQUEST_MS && path !== '/updates') {
384
- console.error(`[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`);
458
+ getMaxLogger().error(
459
+ `[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`,
460
+ );
385
461
  }
386
462
 
387
463
  if (!res.ok) {
388
464
  // Never log the request body: it may carry the webhook secret (POST
389
465
  // /subscriptions) or private message text. Log only method+path+status.
390
- console.error(`[MAX API] ${method} ${path} โ†’ ${res.status}`);
466
+ getMaxLogger().error(`[MAX API] ${method} ${path} โ†’ ${res.status}`);
391
467
  const error = new MaxApiError(
392
468
  `MAX API ${method} ${path} โ†’ ${res.status}`,
393
469
  res.status,
@@ -410,7 +486,7 @@ export class MaxApi {
410
486
  // opaque "This operation was aborted" into a typed, loggable timeout
411
487
  // that records whether a response had started.
412
488
  const phase = gotResponse ? 'reading-body' : 'awaiting-response';
413
- console.error(
489
+ getMaxLogger().error(
414
490
  `[MAX API] ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`,
415
491
  );
416
492
  throw new MaxRequestTimeoutError(method, path, elapsedMs, phase);
@@ -418,7 +494,7 @@ export class MaxApi {
418
494
  if (!isAbort && !(err instanceof MaxApiError)) {
419
495
  // Network-level failure (DNS/TCP/TLS/reset): record timing so a connect
420
496
  // stall is distinguishable from a response-wait stall in the log.
421
- console.error(
497
+ getMaxLogger().error(
422
498
  `[MAX API] ${method} ${path} failed after ${elapsedMs}ms: ${(err as Error).name}`,
423
499
  );
424
500
  }
@@ -458,7 +534,14 @@ export class MaxApi {
458
534
  // โ”€โ”€ Bot info โ”€โ”€
459
535
 
460
536
  async getMe(): Promise<MaxUser> {
461
- return this.request<MaxUser>('GET', '/me');
537
+ const me = await this.request<MaxUser>('GET', '/me');
538
+ if (typeof me?.user_id === 'number') botUserIds.set(this.tokenKey, me.user_id);
539
+ return me;
540
+ }
541
+
542
+ /** The bot's own user id: remembered from a GET /me with this token, else fetched once. */
543
+ async getBotUserId(): Promise<number> {
544
+ return botUserIds.get(this.tokenKey) ?? (await this.getMe()).user_id;
462
545
  }
463
546
 
464
547
  // โ”€โ”€ Messages โ”€โ”€
@@ -542,8 +625,20 @@ export class MaxApi {
542
625
 
543
626
  // โ”€โ”€ Chat actions โ”€โ”€
544
627
 
545
- async sendAction(chatId: number, action: MaxSenderAction): Promise<MaxSimpleResult> {
546
- return this.request('POST', `/chats/${chatId}/actions`, undefined, { action });
628
+ async sendAction(
629
+ chatId: number,
630
+ action: MaxSenderAction,
631
+ opts?: { timeoutMs?: number; retryAttempts?: number; signal?: AbortSignal },
632
+ ): Promise<MaxSimpleResult> {
633
+ return this.request(
634
+ 'POST',
635
+ `/chats/${chatId}/actions`,
636
+ undefined,
637
+ { action },
638
+ opts?.timeoutMs,
639
+ opts?.retryAttempts,
640
+ opts?.signal,
641
+ );
547
642
  }
548
643
 
549
644
  // โ”€โ”€ Callbacks โ”€โ”€
@@ -627,19 +722,23 @@ export class MaxApi {
627
722
  }
628
723
 
629
724
  /**
630
- * Upload media file to MAX.
725
+ * Upload media bytes to MAX.
631
726
  * MAX upload requires multipart/form-data with field "data".
632
727
  * Response contains a token (in photos/videos/etc) to use in attachments.
728
+ * Takes bytes only: local files are read by the callers through the host
729
+ * media access policy (allowed roots, host reader), never by path here.
633
730
  *
634
731
  * @param type Media type (image, video, audio, file)
635
- * @param data File path (string) or buffer (Buffer/Uint8Array)
636
- * @param contentType MIME type (optional, auto-detected for common types)
732
+ * @param data File contents
733
+ * @param contentType MIME type (default application/octet-stream)
734
+ * @param fileName File name for the multipart part (default "file")
637
735
  * @returns Upload result with token (for use in attachment payload)
638
736
  */
639
737
  async uploadMedia(
640
738
  type: 'image' | 'video' | 'audio' | 'file',
641
- data: string | Buffer | Uint8Array,
739
+ data: Buffer | Uint8Array,
642
740
  contentType?: string,
741
+ fileName = 'file',
643
742
  ): Promise<{ token: string; url?: string }> {
644
743
  // Step 1: Get upload URL and attachment token.
645
744
  // MAX returns the attachment token together with the upload URL. The
@@ -648,68 +747,26 @@ export class MaxApi {
648
747
  const uploadInfo = await this.getUploadUrl(type);
649
748
  const uploadUrl = uploadInfo.url;
650
749
 
651
- // Step 2: Load file if data is a path
652
- let fileBuffer: Buffer;
653
- let mimeType = contentType;
654
- let fileName = 'file';
655
-
656
- if (typeof data === 'string') {
657
- // File path โ€” read from disk
658
- const fs = await import('fs/promises');
659
- const path = await import('path');
660
- fileBuffer = await fs.readFile(data);
661
- fileName = path.basename(data);
662
-
663
- // Auto-detect MIME type if not provided
664
- if (!mimeType) {
665
- const ext = data.split('.').pop()?.toLowerCase();
666
- const mimeMap: Record<string, string> = {
667
- jpg: 'image/jpeg',
668
- jpeg: 'image/jpeg',
669
- png: 'image/png',
670
- gif: 'image/gif',
671
- webp: 'image/webp',
672
- heic: 'image/heic',
673
- heif: 'image/heif',
674
- tif: 'image/tiff',
675
- tiff: 'image/tiff',
676
- bmp: 'image/bmp',
677
- mp4: 'video/mp4',
678
- mov: 'video/quicktime',
679
- avi: 'video/x-msvideo',
680
- mkv: 'video/x-matroska',
681
- webm: 'video/webm',
682
- mp3: 'audio/mpeg',
683
- wav: 'audio/wav',
684
- ogg: 'audio/ogg',
685
- m4a: 'audio/mp4',
686
- aac: 'audio/aac',
687
- flac: 'audio/flac',
688
- opus: 'audio/opus',
689
- pdf: 'application/pdf',
690
- txt: 'text/plain',
691
- };
692
- mimeType = ext ? mimeMap[ext] : 'application/octet-stream';
693
- }
694
- } else {
695
- // Already a buffer
696
- fileBuffer = Buffer.from(data);
697
- mimeType = mimeType ?? 'application/octet-stream';
698
- }
750
+ const fileBuffer = Buffer.from(data);
751
+ const mimeType = contentType ?? 'application/octet-stream';
699
752
 
700
- // Step 3: POST file as multipart/form-data to upload URL. MAX upload hosts
753
+ // Step 2: POST file as multipart/form-data to upload URL. MAX upload hosts
701
754
  // reject Node's native FormData in some cases with 412, while curl-style
702
755
  // multipart with a known Content-Length is accepted.
703
756
  const multipart = buildMultipartFileBody('data', fileName, mimeType, fileBuffer);
704
757
 
705
- const uploadRes = await maxFetch(uploadUrl, {
706
- method: 'POST',
707
- headers: {
708
- 'Content-Type': multipart.contentType,
709
- 'Content-Length': String(multipart.body.byteLength),
758
+ const uploadRes = await maxFetch(
759
+ uploadUrl,
760
+ {
761
+ method: 'POST',
762
+ headers: {
763
+ 'Content-Type': multipart.contentType,
764
+ 'Content-Length': String(multipart.body.byteLength),
765
+ },
766
+ body: multipart.body,
710
767
  },
711
- body: multipart.body,
712
- });
768
+ this.network().proxyUrl,
769
+ );
713
770
 
714
771
  if (!uploadRes.ok) {
715
772
  throw new MaxApiError(
@@ -719,7 +776,7 @@ export class MaxApi {
719
776
  );
720
777
  }
721
778
 
722
- // Step 4: Parse response to extract token
779
+ // Step 3: Parse response to extract token
723
780
  // Image response: { photos: { "<id>": { token: "...", url: "..." } } }
724
781
  // Video/audio/file may respond with a top-level token or a similar nested map
725
782
  const result = (await uploadRes.json().catch(() => ({}))) as Record<string, unknown>;
package/src/callbacks.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  import { resolveApprovalOverGateway } from 'openclaw/plugin-sdk/approval-gateway-runtime';
8
8
  import { questionGatewayRuntime } from 'openclaw/plugin-sdk/question-gateway-runtime';
9
9
 
10
- import type { ResolvedMaxAccount } from './accounts.js';
10
+ import { admitMaxGroupMessage } from './access-policy.js';
11
11
  import type { MaxCallback, MaxMessage } from './api.js';
12
+ import { decodeMaxCommandMenuPayload } from './command-menu.js';
12
13
  import { processIncomingMessage } from './inbound.js';
13
14
  import type { MaxMonitorOptions } from './monitor-types.js';
14
15
  import { decodeMaxPresentationCallback, type MaxPresentationCallback } from './presentation.js';
@@ -53,7 +54,17 @@ export async function processCallback(
53
54
  // plain channelData.max.buttons payloads keep arriving as message text.
54
55
  const presentationCallback = decodeMaxPresentationCallback(payload);
55
56
  if (presentationCallback?.kind === 'approval' || presentationCallback?.kind === 'question') {
56
- await resolveMaxRuntimeControlCallback(presentationCallback, callback, opts);
57
+ await resolveMaxRuntimeControlCallback(presentationCallback, callback, message, opts);
58
+ return;
59
+ }
60
+ // Command menu choices re-enter as that command's text; inbound checks the
61
+ // sender's command rights and whether core still offers the choice.
62
+ const menuCommand = decodeMaxCommandMenuPayload(payload);
63
+ if (menuCommand) {
64
+ const menuMessage = Object.assign(buildCallbackMessage(callback, message, menuCommand), {
65
+ __maxCommandMenu: true as const,
66
+ });
67
+ await processIncomingMessage(menuMessage, userLocale, opts);
57
68
  return;
58
69
  }
59
70
  // Opaque callback data goes to the agent labelled, never as a slash command.
@@ -68,30 +79,38 @@ export async function processCallback(
68
79
  /**
69
80
  * Approvals and ask_user answers are operator actions: only senders listed
70
81
  * explicitly in the account's allowFrom may press them (a wildcard is enough
71
- * for questions, never for approvals).
82
+ * for questions, never for approvals). A wildcard answer in a group also
83
+ * needs the group policy and its sender allowlist; with the chat unknown
84
+ * (the keyboard message is gone) the wildcard is not enough.
72
85
  */
73
86
  function isMaxRuntimeControlSender(
74
- account: ResolvedMaxAccount,
87
+ opts: MaxMonitorOptions,
88
+ message: MaxMessage | null,
75
89
  senderId: string,
76
90
  kind: 'approval' | 'question',
77
91
  ): boolean {
92
+ const { account, config } = opts;
78
93
  const allowFrom = (account.config.allowFrom ?? []).map((entry) =>
79
94
  String(entry).trim().replace(/^max:/i, ''),
80
95
  );
81
96
  if (allowFrom.includes(senderId)) return true;
82
- return kind === 'question' && allowFrom.includes('*');
97
+ if (kind !== 'question' || !allowFrom.includes('*') || !message) return false;
98
+ const { chat_id: chatId, chat_type: chatType } = message.recipient;
99
+ if (chatType !== 'chat' && chatType !== 'channel') return true;
100
+ return admitMaxGroupMessage(account, config, chatId, senderId).admitted;
83
101
  }
84
102
 
85
103
  async function resolveMaxRuntimeControlCallback(
86
104
  action: Extract<MaxPresentationCallback, { kind: 'approval' | 'question' }>,
87
105
  callback: MaxCallback,
106
+ message: MaxMessage | null,
88
107
  opts: MaxMonitorOptions,
89
108
  ): Promise<void> {
90
109
  const { account, config, log } = opts;
91
110
  const senderId = String(callback.user.user_id);
92
111
  let notification: string;
93
112
 
94
- if (!isMaxRuntimeControlSender(account, senderId, action.kind)) {
113
+ if (!isMaxRuntimeControlSender(opts, message, senderId, action.kind)) {
95
114
  log?.warn(
96
115
  `[${account.accountId}] MAX ${action.kind} button pressed by unauthorized sender ${senderId}`,
97
116
  );
@@ -117,7 +136,7 @@ async function resolveMaxRuntimeControlCallback(
117
136
  questionId: action.questionId,
118
137
  optionValue: action.optionValue,
119
138
  senderId,
120
- authorize: () => isMaxRuntimeControlSender(account, senderId, 'question'),
139
+ authorize: () => isMaxRuntimeControlSender(opts, message, senderId, 'question'),
121
140
  });
122
141
  notification =
123
142
  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
 
6
7
  import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
@@ -17,31 +18,19 @@ export const maxAgentPromptAdapter: NonNullable<MaxChannelPlugin['agentPrompt']>
17
18
  const emojiMap =
18
19
  '๐Ÿ˜‚: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';
19
20
 
20
- // Also try to load full sticker-emoji-map.json for extended catalog
21
- let extendedHint = '';
22
- try {
23
- const fs = require('fs');
24
- const path = require('path');
25
- const candidates = [
26
- path.join(__dirname, '..', 'sticker-emoji-map.json'),
27
- path.join(process.cwd(), 'projects', 'openclaw-max', 'sticker-emoji-map.json'),
28
- ];
29
- for (const p of candidates) {
30
- if (fs.existsSync(p)) {
31
- extendedHint = ' Full emojiโ†’sticker map available at: ' + p;
32
- break;
33
- }
34
- }
35
- } catch {}
36
-
37
21
  return [
38
22
  '- 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.',
39
- `- Sticker emoji map: ${emojiMap}${extendedHint}`,
23
+ `- Sticker emoji map: ${emojiMap}`,
40
24
  '- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
41
25
  '- MAX contact: use `message(action="sendAttachment", target="CHAT_ID", type="contact", contactName="Name", vcfPhone="+70001234567")` to send a native contact card.',
42
26
  '- MAX pin: use `message(action="pin", target="CHAT_ID", messageId="MID")` to pin a message; `message(action="unpin", target="CHAT_ID")` to unpin.',
43
27
  '- 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"}]].',
44
28
  '- 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.',
29
+ '- 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.',
30
+ '- MAX files without a path: `sendAttachment` takes `buffer` (base64 or a data URL) with `filename` and `contentType`.',
31
+ '- MAX quiet sends: `silent=true` sends without a notification (MAX channels always notify).',
32
+ '- 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.',
33
+ "- 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.",
45
34
  ];
46
35
  },
47
36
  };
@@ -13,7 +13,14 @@ import {
13
13
  setAccountEnabledInConfigSection,
14
14
  } from 'openclaw/plugin-sdk/core';
15
15
 
16
- import { listMaxAccountIds, type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
16
+ import {
17
+ isMaxAccountConfigured,
18
+ listMaxAccountIds,
19
+ readMaxAccount,
20
+ type ResolvedMaxAccount,
21
+ resolveMaxAccount,
22
+ } from './accounts.js';
23
+ import { resolveMaxTransport } from './monitor-types.js';
17
24
 
18
25
  type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
19
26
 
@@ -21,6 +28,24 @@ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
21
28
  export const maxConfigAdapter: NonNullable<MaxChannelPlugin['config']> = {
22
29
  listAccountIds: (cfg) => listMaxAccountIds(cfg),
23
30
  resolveAccount: (cfg, accountId) => resolveMaxAccount({ cfg, accountId }),
31
+
32
+ // Read-only status (openclaw status / channels status, doctor): where the
33
+ // token comes from and whether it is usable in this command path, never
34
+ // the token itself. A SecretRef the command cannot read is
35
+ // configured_unavailable, not "not configured".
36
+ inspectAccount: (cfg, accountId) => {
37
+ const account = readMaxAccount({ cfg, accountId });
38
+ return {
39
+ accountId: account.accountId,
40
+ ...(account.name ? { name: account.name } : {}),
41
+ enabled: account.enabled,
42
+ configured: isMaxAccountConfigured(account),
43
+ tokenSource: account.tokenSource,
44
+ tokenStatus: account.tokenStatus,
45
+ mode: resolveMaxTransport(account.config),
46
+ ...(account.stateReason ? { stateReason: account.stateReason } : {}),
47
+ };
48
+ },
24
49
  defaultAccountId: () => DEFAULT_ACCOUNT_ID,
25
50
 
26
51
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
@@ -40,14 +65,18 @@ export const maxConfigAdapter: NonNullable<MaxChannelPlugin['config']> = {
40
65
  clearBaseFields: ['botToken', 'tokenFile', 'name'],
41
66
  }),
42
67
 
43
- isConfigured: (account) => Boolean(account.token?.trim()),
68
+ // An unresolved SecretRef token counts as configured: startAccount then
69
+ // fails with its config path instead of the account being silently skipped.
70
+ isConfigured: (account) => isMaxAccountConfigured(account),
44
71
 
45
72
  describeAccount: (account) => ({
46
73
  accountId: account.accountId,
47
74
  name: account.name,
48
75
  enabled: account.enabled,
49
- configured: Boolean(account.token?.trim()),
76
+ configured: isMaxAccountConfigured(account),
50
77
  tokenSource: account.tokenSource,
78
+ tokenStatus: account.tokenStatus,
79
+ ...(account.stateReason ? { stateReason: account.stateReason } : {}),
51
80
  }),
52
81
 
53
82
  resolveAllowFrom: ({ cfg, accountId }) =>