@aspalagin/openclaw-max 0.7.2 → 0.8.0

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