@aspalagin/openclaw-max 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/dist/src/media-temp.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote media
|
|
3
|
-
*
|
|
2
|
+
* Remote media download for MAX uploads and the file name policy shared with
|
|
3
|
+
* inbound media. The download goes through the runtime media fetcher (core's
|
|
4
|
+
* SSRF-guarded reader); bytes stay in memory, no temporary file.
|
|
4
5
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
6
|
+
import { proxiedMediaFetchOptions, scrubProxyCredentials } from './network.js';
|
|
8
7
|
import { getMaxRuntime } from './runtime.js';
|
|
8
|
+
/**
|
|
9
|
+
* runtime.channel.media.fetchRemoteMedia, routed through the account proxy
|
|
10
|
+
* when one is set; errors never carry the proxy credentials.
|
|
11
|
+
*/
|
|
12
|
+
export async function fetchMaxRemoteMedia(url, maxBytes, proxyUrl) {
|
|
13
|
+
try {
|
|
14
|
+
return await getMaxRuntime().channel.media.fetchRemoteMedia({
|
|
15
|
+
url,
|
|
16
|
+
maxBytes,
|
|
17
|
+
...proxiedMediaFetchOptions(proxyUrl),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
throw scrubProxyCredentials(err, proxyUrl);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
9
24
|
/** Upper bound for a sanitized file name (bytes of UTF-8 stay well under 255). */
|
|
10
25
|
export const MAX_TEMP_FILE_NAME_LENGTH = 120;
|
|
11
26
|
const EXTENSION_BY_CONTENT_TYPE = {
|
|
@@ -65,22 +80,18 @@ export function sanitizeMaxFileName(name, contentType) {
|
|
|
65
80
|
return ext ? `${stem}.${ext}` : stem;
|
|
66
81
|
}
|
|
67
82
|
/**
|
|
68
|
-
* Download `url` through the runtime media fetcher
|
|
69
|
-
*
|
|
70
|
-
*
|
|
83
|
+
* Download `url` through the runtime media fetcher: core's readRemoteMediaBuffer
|
|
84
|
+
* (fetchWithSsrFGuard, strict mode: private, loopback and metadata hosts are
|
|
85
|
+
* refused), size-capped, through the account proxy when one is set. The file
|
|
86
|
+
* name is sanitized to one safe segment.
|
|
71
87
|
*/
|
|
72
|
-
export async function
|
|
73
|
-
const loaded = await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return await use({ path, contentType: loaded.contentType, fileName });
|
|
80
|
-
}
|
|
81
|
-
finally {
|
|
82
|
-
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
83
|
-
}
|
|
88
|
+
export async function downloadMaxRemoteMedia(url, maxBytes, proxyUrl) {
|
|
89
|
+
const loaded = await fetchMaxRemoteMedia(url, maxBytes, proxyUrl);
|
|
90
|
+
return {
|
|
91
|
+
buffer: Buffer.from(loaded.buffer),
|
|
92
|
+
contentType: loaded.contentType,
|
|
93
|
+
fileName: sanitizeMaxFileName(loaded.fileName ?? fileNameFromUrl(url), loaded.contentType),
|
|
94
|
+
};
|
|
84
95
|
}
|
|
85
96
|
function fileNameFromUrl(url) {
|
|
86
97
|
try {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-account network settings: MAX API base URL and an outbound HTTP(S) proxy.
|
|
3
|
+
*
|
|
4
|
+
* The settings are bound to the bot token when the account is resolved
|
|
5
|
+
* (resolveMaxAccount), and every MaxApi built for that token picks them up —
|
|
6
|
+
* whichever send/action/lifecycle path built it. A bad value is bound as an
|
|
7
|
+
* error, so requests fail with it instead of silently going out directly.
|
|
8
|
+
*
|
|
9
|
+
* Proxy credentials (http://user:pass@host:port) never reach logs or errors:
|
|
10
|
+
* use redactProxyUrl() for display and scrubProxyCredentials() on errors that
|
|
11
|
+
* crossed the proxy.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { isIP } from 'node:net';
|
|
15
|
+
/** Canonical MAX Bot API endpoint (platform-api.max.ru was shut down on 2026-07-19). */
|
|
16
|
+
export const DEFAULT_MAX_API_BASE_URL = 'https://platform-api2.max.ru';
|
|
17
|
+
/** A misconfigured apiBaseUrl/httpProxy; never retried, safe to log. */
|
|
18
|
+
export class MaxNetworkConfigError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'MaxNetworkConfigError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Loopback host: localhost names, 127.0.0.0/8, ::1 (URL.hostname form). */
|
|
25
|
+
export function isLoopbackHost(hostname) {
|
|
26
|
+
const host = hostname
|
|
27
|
+
.replace(/^\[|\]$/g, '')
|
|
28
|
+
.replace(/\.$/, '')
|
|
29
|
+
.toLowerCase();
|
|
30
|
+
if (host === 'localhost' || host.endsWith('.localhost'))
|
|
31
|
+
return true;
|
|
32
|
+
if (isIP(host) === 4)
|
|
33
|
+
return host.startsWith('127.');
|
|
34
|
+
if (isIP(host) === 6)
|
|
35
|
+
return host === '::1' || host.startsWith('::ffff:127.');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
/** Whether requests to `url` bypass the proxy (loopback targets never use it). */
|
|
39
|
+
export function bypassesProxy(url) {
|
|
40
|
+
try {
|
|
41
|
+
return isLoopbackHost(new URL(url).hostname);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Proxy URL for display: scheme, host and port; credentials become "***". */
|
|
48
|
+
export function redactProxyUrl(proxyUrl) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(proxyUrl);
|
|
51
|
+
const auth = url.username || url.password ? '***@' : '';
|
|
52
|
+
return `${url.protocol}//${auth}${url.host}`;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return '<invalid URL>';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Secret fragments of a proxy URL, as they may appear in error text. */
|
|
59
|
+
function proxySecrets(proxyUrl) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(proxyUrl);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [proxyUrl];
|
|
66
|
+
}
|
|
67
|
+
if (!url.username && !url.password)
|
|
68
|
+
return [];
|
|
69
|
+
const secrets = [proxyUrl, url.href];
|
|
70
|
+
for (const part of [url.password, url.username]) {
|
|
71
|
+
if (!part)
|
|
72
|
+
continue;
|
|
73
|
+
secrets.push(part);
|
|
74
|
+
try {
|
|
75
|
+
secrets.push(decodeURIComponent(part));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// keep the encoded form only
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// longest first, so a URL is replaced whole before its parts
|
|
82
|
+
return [...new Set(secrets.filter((s) => s.length > 0))].sort((a, b) => b.length - a.length);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Remove proxy credentials from an error (message, and its cause chain) in
|
|
86
|
+
* place and return it. No-op when the proxy URL carries no credentials.
|
|
87
|
+
*/
|
|
88
|
+
export function scrubProxyCredentials(err, proxyUrl) {
|
|
89
|
+
if (!proxyUrl)
|
|
90
|
+
return err;
|
|
91
|
+
const secrets = proxySecrets(proxyUrl);
|
|
92
|
+
if (secrets.length === 0)
|
|
93
|
+
return err;
|
|
94
|
+
const scrub = (text) => secrets.reduce((acc, secret) => acc.split(secret).join('***'), text);
|
|
95
|
+
let current = err;
|
|
96
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
97
|
+
if (secrets.some((s) => current instanceof Error && current.message.includes(s))) {
|
|
98
|
+
current.message = scrub(current.message);
|
|
99
|
+
}
|
|
100
|
+
if (current.stack &&
|
|
101
|
+
secrets.some((s) => current instanceof Error && current.stack?.includes(s))) {
|
|
102
|
+
current.stack = scrub(current.stack);
|
|
103
|
+
}
|
|
104
|
+
current = current.cause;
|
|
105
|
+
}
|
|
106
|
+
return err;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Validate and normalize the account's apiBaseUrl/httpProxy. `configPath` is
|
|
110
|
+
* the config prefix for messages ("channels.max" or "channels.max.accounts.x").
|
|
111
|
+
* Throws MaxNetworkConfigError; messages never echo proxy credentials.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveMaxNetwork(config, configPath = 'channels.max') {
|
|
114
|
+
return {
|
|
115
|
+
apiBaseUrl: resolveApiBaseUrl(config.apiBaseUrl, `${configPath}.apiBaseUrl`),
|
|
116
|
+
proxyUrl: resolveProxyUrl(config.httpProxy, `${configPath}.httpProxy`),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function resolveApiBaseUrl(value, path) {
|
|
120
|
+
if (value === undefined || value === null)
|
|
121
|
+
return DEFAULT_MAX_API_BASE_URL;
|
|
122
|
+
if (typeof value !== 'string')
|
|
123
|
+
throw new MaxNetworkConfigError(`${path} must be a string URL`);
|
|
124
|
+
const raw = value.trim();
|
|
125
|
+
if (!raw)
|
|
126
|
+
return DEFAULT_MAX_API_BASE_URL;
|
|
127
|
+
let url;
|
|
128
|
+
try {
|
|
129
|
+
url = new URL(raw);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new MaxNetworkConfigError(`${path} is not a valid URL (expected https://host[/path])`);
|
|
133
|
+
}
|
|
134
|
+
if (url.username || url.password) {
|
|
135
|
+
throw new MaxNetworkConfigError(`${path} must not contain credentials`);
|
|
136
|
+
}
|
|
137
|
+
if (url.search || url.hash) {
|
|
138
|
+
throw new MaxNetworkConfigError(`${path} must not contain a query or fragment: ${url.origin}`);
|
|
139
|
+
}
|
|
140
|
+
if (url.protocol === 'http:') {
|
|
141
|
+
// The bot token rides in the Authorization header of every call.
|
|
142
|
+
if (!isLoopbackHost(url.hostname)) {
|
|
143
|
+
throw new MaxNetworkConfigError(`${path} must use https (http is allowed only for loopback hosts, the bot token would travel unencrypted): ${url.origin}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (url.protocol !== 'https:') {
|
|
147
|
+
throw new MaxNetworkConfigError(`${path} must be an https URL: ${url.origin}`);
|
|
148
|
+
}
|
|
149
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
|
|
150
|
+
}
|
|
151
|
+
function resolveProxyUrl(value, path) {
|
|
152
|
+
if (value === undefined || value === null)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (typeof value !== 'string')
|
|
155
|
+
throw new MaxNetworkConfigError(`${path} must be a string URL`);
|
|
156
|
+
const raw = value.trim();
|
|
157
|
+
// "" turns an inherited channel-level proxy off for one account.
|
|
158
|
+
if (!raw)
|
|
159
|
+
return undefined;
|
|
160
|
+
let url;
|
|
161
|
+
try {
|
|
162
|
+
url = new URL(raw);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new MaxNetworkConfigError(`${path} is not a valid URL (expected http://host:port)`);
|
|
166
|
+
}
|
|
167
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
168
|
+
throw new MaxNetworkConfigError(`${path} must be an http:// or https:// proxy URL (got ${url.protocol}//…)`);
|
|
169
|
+
}
|
|
170
|
+
if (!url.hostname) {
|
|
171
|
+
throw new MaxNetworkConfigError(`${path} has no host: ${redactProxyUrl(raw)}`);
|
|
172
|
+
}
|
|
173
|
+
return raw;
|
|
174
|
+
}
|
|
175
|
+
const bindings = new Map();
|
|
176
|
+
/** Registry key: a token digest, not the token itself. */
|
|
177
|
+
export function maxTokenKey(token) {
|
|
178
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
|
179
|
+
}
|
|
180
|
+
/** Bind the account's network settings (or its config error) to its token. */
|
|
181
|
+
export function bindMaxNetwork(token, binding) {
|
|
182
|
+
if (!token)
|
|
183
|
+
return;
|
|
184
|
+
bindings.set(maxTokenKey(token), binding);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Network settings bound to a token; the default endpoint, direct, when the
|
|
188
|
+
* token was never resolved from config (e.g. a setup-wizard probe). Throws the
|
|
189
|
+
* bound config error.
|
|
190
|
+
*/
|
|
191
|
+
export function lookupMaxNetwork(token) {
|
|
192
|
+
const binding = bindings.get(maxTokenKey(token));
|
|
193
|
+
if (!binding)
|
|
194
|
+
return { apiBaseUrl: DEFAULT_MAX_API_BASE_URL };
|
|
195
|
+
if ('error' in binding)
|
|
196
|
+
throw binding.error;
|
|
197
|
+
return binding;
|
|
198
|
+
}
|
|
199
|
+
export function resetMaxNetworkBindingsForTests() {
|
|
200
|
+
bindings.clear();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Options for runtime.channel.media.fetchRemoteMedia routing a download
|
|
204
|
+
* through the account proxy (core SSRF guard, explicit-proxy dispatcher; the
|
|
205
|
+
* proxy is operator-configured, so it may sit on a private address, and it
|
|
206
|
+
* resolves the target DNS after the guard's hostname checks — as core's
|
|
207
|
+
* Telegram channel does for its explicit proxy).
|
|
208
|
+
*/
|
|
209
|
+
export function proxiedMediaFetchOptions(proxyUrl) {
|
|
210
|
+
if (!proxyUrl)
|
|
211
|
+
return {};
|
|
212
|
+
return {
|
|
213
|
+
dispatcherPolicy: { mode: 'explicit-proxy', proxyUrl, allowPrivateProxy: true },
|
|
214
|
+
trustExplicitProxyDns: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
package/dist/src/onboarding.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
|
|
5
5
|
import { formatDocsLink } from 'openclaw/plugin-sdk/setup';
|
|
6
|
-
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
6
|
+
import { listMaxAccountIds, resolveMaxAccount, resolveMaxAccountNetwork } from './accounts.js';
|
|
7
7
|
import { MaxApi } from './api.js';
|
|
8
8
|
const channel = 'max';
|
|
9
9
|
const ENV_MAX_BOT_TOKEN = 'MAX_BOT_TOKEN';
|
|
@@ -122,7 +122,9 @@ export const maxSetupWizard = {
|
|
|
122
122
|
const tokenValue = String(resolvedValue).trim();
|
|
123
123
|
// Verify token by calling GET /me
|
|
124
124
|
try {
|
|
125
|
-
|
|
125
|
+
// Probe over the account's apiBaseUrl/httpProxy: the new token is not bound yet.
|
|
126
|
+
const network = resolveMaxAccountNetwork(resolveMaxAccount({ cfg, accountId }));
|
|
127
|
+
const api = new MaxApi({ token: tokenValue, timeoutMs: 5000, network });
|
|
126
128
|
const me = await api.getMe();
|
|
127
129
|
console.log(`✓ Token verified! Bot: ${me.first_name}${me.username ? ` (@${me.username})` : ''} (ID: ${me.user_id})`);
|
|
128
130
|
}
|
package/dist/src/polling.js
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
* start-up cleanup of webhook subscriptions that would silence polling.
|
|
4
4
|
*/
|
|
5
5
|
import { channelReadyPatch, createTransportActivityStatusPatch, } from 'openclaw/plugin-sdk/gateway-runtime';
|
|
6
|
+
import { computeBackoff, sleepWithAbort, } from 'openclaw/plugin-sdk/runtime-env';
|
|
7
|
+
import { MaxApiError } from './api.js';
|
|
6
8
|
import { dispatchUpdate } from './dispatch.js';
|
|
9
|
+
import { isMaxGatewayDrainingError, waitForMaxDrainRetry } from './gateway-drain.js';
|
|
7
10
|
import { MAX_SUBSCRIBED_UPDATE_TYPES, } from './monitor-types.js';
|
|
11
|
+
import { runWithMaxTurnAdoption } from './turn-adoption.js';
|
|
12
|
+
import { openMaxUpdateJournal } from './update-journal.js';
|
|
13
|
+
import { maxUpdateDedupeKey } from './webhook.js';
|
|
14
|
+
import { resolveMaxEventMaxAgeMs, staleMaxUpdateAgeMs } from './webhook-queue.js';
|
|
8
15
|
/**
|
|
9
16
|
* Polling mode: MAX stops serving GET /updates while any webhook subscription
|
|
10
17
|
* exists, so a leftover subscription (another deploy, a manual test) would
|
|
@@ -31,10 +38,170 @@ export async function clearMaxSubscriptionsForPolling(opts) {
|
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
40
|
}
|
|
41
|
+
/** Pause after failed polls: 2 s doubling up to 60 s, plus up to 20% jitter; a good poll resets it. */
|
|
42
|
+
const POLL_BACKOFF = { initialMs: 2_000, maxMs: 60_000, factor: 2, jitter: 0.2 };
|
|
43
|
+
/** A rejected token (401) does not heal in seconds: retry rarely instead of at the error pace. */
|
|
44
|
+
export const POLL_AUTH_PAUSE_MS = 5 * 60_000;
|
|
45
|
+
/** Ceiling for a server Retry-After, so one answer cannot silence the bot for hours. */
|
|
46
|
+
const POLL_MAX_RETRY_AFTER_MS = 10 * 60_000;
|
|
47
|
+
function isAuthRejected(err) {
|
|
48
|
+
return err instanceof MaxApiError && err.status === 401;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Delay before the next poll after `failures` failed polls in a row: the
|
|
52
|
+
* exponential backoff, at least the server's Retry-After (bounded), and a
|
|
53
|
+
* long fixed pause for a rejected token.
|
|
54
|
+
* @internal exported for testing.
|
|
55
|
+
*/
|
|
56
|
+
export function resolvePollRetryDelayMs(err, failures) {
|
|
57
|
+
if (isAuthRejected(err))
|
|
58
|
+
return POLL_AUTH_PAUSE_MS;
|
|
59
|
+
const backoffMs = computeBackoff(POLL_BACKOFF, failures);
|
|
60
|
+
const retryAfterMs = err instanceof MaxApiError ? err.retryAfterMs : undefined;
|
|
61
|
+
if (!retryAfterMs)
|
|
62
|
+
return backoffMs;
|
|
63
|
+
return Math.max(backoffMs, Math.min(retryAfterMs, POLL_MAX_RETRY_AFTER_MS));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Dedupe keys of handled updates survive restarts in the account journal: a
|
|
67
|
+
* restart mid-batch re-fetches the whole batch (the marker is persisted only
|
|
68
|
+
* after it), and core's inbound dedupe lives in process memory. Undefined when
|
|
69
|
+
* the state directory is not writable (then replays are dispatched again).
|
|
70
|
+
*/
|
|
71
|
+
async function openPollingJournal(opts) {
|
|
72
|
+
const { account, log } = opts;
|
|
73
|
+
try {
|
|
74
|
+
return await openMaxUpdateJournal({
|
|
75
|
+
accountId: account.accountId,
|
|
76
|
+
warn: (message) => log?.warn(`[${account.accountId}] ${message}`),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const code = err?.code;
|
|
81
|
+
log?.warn(`[${account.accountId}] MAX update journal unavailable (${typeof code === 'string' ? code : String(err)}); updates handled before a restart may be handled again`);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One polled update: skip a handled or stale one, dispatch, remember it.
|
|
87
|
+
* False when the channel stopped while the draining gateway refused it: not
|
|
88
|
+
* handled, not remembered.
|
|
89
|
+
*/
|
|
90
|
+
async function handlePolledUpdate(update, opts, journal, maxAgeMs) {
|
|
91
|
+
const { account, log } = opts;
|
|
92
|
+
const key = maxUpdateDedupeKey(update);
|
|
93
|
+
if (key && journal?.hasCompleted(key)) {
|
|
94
|
+
log?.debug?.(`[${account.accountId}] MAX polling: ${update.update_type} already handled, skipped`);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
let remembered;
|
|
98
|
+
const remember = () => (remembered ??= key && journal ? journal.remember(key) : Promise.resolve());
|
|
99
|
+
const staleMs = staleMaxUpdateAgeMs(update, maxAgeMs, Date.now());
|
|
100
|
+
if (staleMs !== undefined) {
|
|
101
|
+
log?.warn(`[${account.accountId}] MAX polling: ${update.update_type} from ${Math.round(staleMs / 60_000)} min ago skipped (older than maxEventAgeMinutes)`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
try {
|
|
105
|
+
const durable = Boolean(key && journal);
|
|
106
|
+
const adopted = () => remembered !== undefined;
|
|
107
|
+
if (!(await dispatchThroughDrain(remember, adopted, update, opts, durable)))
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
log?.error(`[${account.accountId}] Error dispatching update ${update.update_type}: ${String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await remember();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Dispatch; with `durable`, `done` also runs at core's turn adoption. Core then
|
|
119
|
+
* owns the turn and resumes it after a crash by itself: marking the update
|
|
120
|
+
* handled only when the dispatch returns would replay it after the restart and
|
|
121
|
+
* run the turn twice (see turn-adoption.ts).
|
|
122
|
+
*/
|
|
123
|
+
function dispatchWithAdoption(done, update, opts, durable) {
|
|
124
|
+
const dispatch = () => dispatchUpdate(update, opts);
|
|
125
|
+
return durable ? runWithMaxTurnAdoption(done, dispatch) : dispatch();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* dispatchWithAdoption, again after a pause while the draining gateway refuses
|
|
129
|
+
* a durable update before core adopted its turn: the turn never started (see
|
|
130
|
+
* gateway-drain.ts). False when the channel stopped first; other errors throw.
|
|
131
|
+
*/
|
|
132
|
+
async function dispatchThroughDrain(done, adopted, update, opts, durable) {
|
|
133
|
+
const { account, log } = opts;
|
|
134
|
+
let refusals = 0;
|
|
135
|
+
for (;;) {
|
|
136
|
+
try {
|
|
137
|
+
await dispatchWithAdoption(done, update, opts, durable);
|
|
138
|
+
if (refusals > 0) {
|
|
139
|
+
log?.info(`[${account.accountId}] MAX polling: ${update.update_type} accepted after ${refusals} refusal(s) of the draining gateway`);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (!durable || adopted() || !isMaxGatewayDrainingError(err))
|
|
145
|
+
throw err;
|
|
146
|
+
refusals += 1;
|
|
147
|
+
if (refusals === 1) {
|
|
148
|
+
log?.warn(`[${account.accountId}] MAX polling: the gateway is draining and refused ${update.update_type}; retried until accepted or the channel stops (then after the restart)`);
|
|
149
|
+
}
|
|
150
|
+
if (!(await waitForMaxDrainRetry(refusals, opts.abortSignal)))
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Updates a webhook-mode run accepted but did not handle before the transport
|
|
157
|
+
* was switched to polling: handle them first, in order.
|
|
158
|
+
*/
|
|
159
|
+
async function drainWebhookLeftovers(opts, journal, maxAgeMs) {
|
|
160
|
+
const { account, log } = opts;
|
|
161
|
+
let pending;
|
|
162
|
+
try {
|
|
163
|
+
pending = await journal.readPending();
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
log?.error(`[${account.accountId}] MAX update journal read failed: ${String(err)}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (pending.entries.length + pending.unreadable === 0)
|
|
170
|
+
return;
|
|
171
|
+
log?.warn(`[${account.accountId}] MAX polling: ${pending.entries.length} webhook update(s) accepted before the restart handled first, ${pending.unreadable} unreadable dropped`);
|
|
172
|
+
for (const entry of pending.entries) {
|
|
173
|
+
if (opts.abortSignal.aborted) {
|
|
174
|
+
journal.release(entry);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let completed;
|
|
178
|
+
const complete = () => (completed ??= journal.complete(entry));
|
|
179
|
+
const staleMs = staleMaxUpdateAgeMs(entry.update, maxAgeMs, Date.now(), entry.receivedAt);
|
|
180
|
+
if (staleMs === undefined) {
|
|
181
|
+
try {
|
|
182
|
+
const adopted = () => completed !== undefined;
|
|
183
|
+
if (!(await dispatchThroughDrain(complete, adopted, entry.update, opts, true))) {
|
|
184
|
+
// Stopped while the draining gateway refused it: the next start takes it.
|
|
185
|
+
journal.release(entry);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
log?.error(`[${account.accountId}] Error dispatching update ${entry.update.update_type}: ${String(err)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await complete();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
34
196
|
export async function startMaxPollingLoop(opts) {
|
|
35
197
|
const { api, account, abortSignal, log, statusSink } = opts;
|
|
36
198
|
let marker = opts.state?.marker ?? null;
|
|
37
199
|
log?.info(`[${account.accountId}] MAX long-polling started${marker != null ? ` (resuming from marker ${marker})` : ''}`);
|
|
200
|
+
const maxAgeMs = resolveMaxEventMaxAgeMs(account.config.maxEventAgeMinutes);
|
|
201
|
+
const journal = await openPollingJournal(opts);
|
|
202
|
+
if (journal)
|
|
203
|
+
await drainWebhookLeftovers(opts, journal, maxAgeMs);
|
|
204
|
+
let failures = 0;
|
|
38
205
|
while (!abortSignal.aborted) {
|
|
39
206
|
try {
|
|
40
207
|
// The channel abort signal must reach the request: a 35s long poll that
|
|
@@ -46,6 +213,7 @@ export async function startMaxPollingLoop(opts) {
|
|
|
46
213
|
types: MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
47
214
|
signal: abortSignal,
|
|
48
215
|
});
|
|
216
|
+
failures = 0;
|
|
49
217
|
// A completed poll is the transport proof the gateway waits for: it moves
|
|
50
218
|
// the account out of lifecycle "starting", clears a stale lastError, and
|
|
51
219
|
// refreshes the timestamp the health policy uses to spot a dead socket.
|
|
@@ -63,16 +231,14 @@ export async function startMaxPollingLoop(opts) {
|
|
|
63
231
|
batchCompleted = false;
|
|
64
232
|
break;
|
|
65
233
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
catch (err) {
|
|
70
|
-
log?.error(`[${account.accountId}] Error dispatching update ${update.update_type}: ${String(err)}`);
|
|
234
|
+
if (!(await handlePolledUpdate(update, opts, journal, maxAgeMs))) {
|
|
235
|
+
batchCompleted = false;
|
|
236
|
+
break;
|
|
71
237
|
}
|
|
72
238
|
}
|
|
73
239
|
// …but only PERSIST the marker after the whole batch is handled. A restart
|
|
74
240
|
// mid-batch then resumes from before the unprocessed updates (at-least-once);
|
|
75
|
-
//
|
|
241
|
+
// the journal's dedupe keys skip the ones already handled.
|
|
76
242
|
if (batchCompleted && !abortSignal.aborted && resp.marker != null) {
|
|
77
243
|
opts.state?.setMarker(resp.marker);
|
|
78
244
|
}
|
|
@@ -80,19 +246,21 @@ export async function startMaxPollingLoop(opts) {
|
|
|
80
246
|
catch (err) {
|
|
81
247
|
if (abortSignal.aborted)
|
|
82
248
|
break;
|
|
83
|
-
|
|
249
|
+
failures += 1;
|
|
250
|
+
const delayMs = resolvePollRetryDelayMs(err, failures);
|
|
251
|
+
const retryIn = `retrying in ${Math.round(delayMs / 1000)}s`;
|
|
252
|
+
log?.error(isAuthRejected(err)
|
|
253
|
+
? `[${account.accountId}] MAX rejected the bot token (401): check botToken/tokenFile; ${retryIn}`
|
|
254
|
+
: `[${account.accountId}] Polling error: ${String(err)}; ${retryIn}`);
|
|
84
255
|
// Record the error but keep `connected` untouched: this loop owns its
|
|
85
256
|
// retries, and flipping to disconnected on a transient blip would hand
|
|
86
257
|
// the health monitor a restart trigger. A genuinely dead transport is
|
|
87
258
|
// caught by lastTransportActivityAt going stale instead.
|
|
88
259
|
statusSink?.({ lastError: String(err) });
|
|
89
|
-
//
|
|
90
|
-
await
|
|
260
|
+
// The account stop ends the pause at once (sleepWithAbort rejects).
|
|
261
|
+
await sleepWithAbort(delayMs, abortSignal).catch(() => undefined);
|
|
91
262
|
}
|
|
92
263
|
}
|
|
93
264
|
statusSink?.({ connected: false });
|
|
94
265
|
log?.info(`[${account.accountId}] MAX long-polling stopped`);
|
|
95
266
|
}
|
|
96
|
-
function sleep(ms) {
|
|
97
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
-
}
|
package/dist/src/presentation.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - url / URL-backed web-app → a MAX `link` button.
|
|
21
21
|
*/
|
|
22
22
|
import { renderMessagePresentationFallbackText, renderPresentationForDelivery, resolveMessagePresentationButtonAction, resolveMessagePresentationOptionAction, } from 'openclaw/plugin-sdk/interactive-runtime';
|
|
23
|
+
import { resolveTextChunkLimit } from 'openclaw/plugin-sdk/reply-chunking';
|
|
23
24
|
/** MAX Bot API limits (schema.yaml: Button.text, CallbackButton.payload, InlineKeyboard). */
|
|
24
25
|
export const MAX_BUTTON_TEXT_LIMIT = 128;
|
|
25
26
|
export const MAX_CALLBACK_PAYLOAD_BYTES = 1024;
|
|
@@ -27,6 +28,16 @@ export const MAX_KEYBOARD_ROWS = 30;
|
|
|
27
28
|
export const MAX_KEYBOARD_BUTTONS = 210;
|
|
28
29
|
export const MAX_LINK_URL_LENGTH = 2048;
|
|
29
30
|
export const MAX_TEXT_LIMIT = 4000;
|
|
31
|
+
/**
|
|
32
|
+
* Chunk size of outgoing text: channels.max.textChunkLimit (account first, as
|
|
33
|
+
* core resolves it) may lower it, never above MAX's 4000-character cap.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveMaxTextChunkLimit(cfg, accountId) {
|
|
36
|
+
const configured = resolveTextChunkLimit(cfg, 'max', accountId, {
|
|
37
|
+
fallbackLimit: MAX_TEXT_LIMIT,
|
|
38
|
+
});
|
|
39
|
+
return Math.min(configured, MAX_TEXT_LIMIT);
|
|
40
|
+
}
|
|
30
41
|
/** Buttons per row we render: within MAX's 7 (3 for link/open_app/request_*). */
|
|
31
42
|
const BUTTONS_PER_ROW = 3;
|
|
32
43
|
const SELECT_OPTIONS_PER_ROW = 2;
|