@aspalagin/openclaw-max 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
@@ -1,11 +1,26 @@
1
1
  /**
2
- * Remote media → temporary local file, for MAX uploads that need a path.
3
- * One place for the download, the file name policy and the cleanup.
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 { mkdtemp, rm, writeFile } from 'node:fs/promises';
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 (size-capped), write it to
69
- * a private temp directory under a sanitized name, run `use`, then remove the
70
- * directory whatever happens.
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 withRemoteMediaTempFile(url, maxBytes, use) {
73
- const loaded = await getMaxRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
74
- const fileName = sanitizeMaxFileName(loaded.fileName ?? fileNameFromUrl(url), loaded.contentType);
75
- const dir = await mkdtemp(join(tmpdir(), 'max-media-'));
76
- try {
77
- const path = join(dir, fileName);
78
- await writeFile(path, loaded.buffer, { mode: 0o600 });
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
+ }
@@ -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
- const api = new MaxApi({ token: tokenValue, timeoutMs: 5000 });
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
  }
@@ -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
- try {
67
- await dispatchUpdate(update, opts);
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
- // OpenClaw dedups replays by mid, so re-delivery is safe but loss is not.
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
- log?.error(`[${account.accountId}] Polling error: ${String(err)}`);
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
- // Back off on error
90
- await sleep(3000);
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
- }
@@ -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;