@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/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
|
|
146
|
+
function getDirectMaxDispatcher(): Agent {
|
|
114
147
|
if (!cachedDispatcher) {
|
|
115
|
-
const
|
|
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
|
-
|
|
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(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
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(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
546
|
-
|
|
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
|
|
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
|
|
636
|
-
* @param contentType MIME type (
|
|
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:
|
|
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
|
-
|
|
652
|
-
|
|
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
|
|
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(
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
'
|
|
709
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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}
|
|
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
|
};
|
package/src/channel-config.ts
CHANGED
|
@@ -13,7 +13,14 @@ import {
|
|
|
13
13
|
setAccountEnabledInConfigSection,
|
|
14
14
|
} from 'openclaw/plugin-sdk/core';
|
|
15
15
|
|
|
16
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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 }) =>
|