@aspalagin/openclaw-max 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-policy.js +19 -6
- 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 +983 -8
- package/package.json +17 -4
- 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-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/src/media-temp.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
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
6
|
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
96
|
+
export async function downloadMaxRemoteMedia(
|
|
81
97
|
url: string,
|
|
82
98
|
maxBytes: number,
|
|
83
|
-
|
|
84
|
-
): Promise<
|
|
85
|
-
const loaded = await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
-
await
|
|
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
|
-
}
|
package/src/presentation.ts
CHANGED
|
@@ -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;
|