@aspalagin/openclaw-max 0.7.2
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 +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
package/dist/src/api.js
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAX Bot API client — thin wrapper around platform-api2.max.ru
|
|
3
|
+
*
|
|
4
|
+
* API docs: https://dev.max.ru/docs-api
|
|
5
|
+
* Auth: Authorization header with bot token
|
|
6
|
+
* Rate limit: 30 rps
|
|
7
|
+
*
|
|
8
|
+
* TLS: platform-api2.max.ru serves a certificate issued by the Russian Trusted
|
|
9
|
+
* (Минцифры) CA, which is not in the default Node trust store. Requests go
|
|
10
|
+
* through a dedicated undici dispatcher whose CA set = system roots + Russian
|
|
11
|
+
* Trusted Root/Sub CA. Trust is scoped to this client only, never process-wide.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import * as tls from 'node:tls';
|
|
15
|
+
import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
|
|
16
|
+
import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
|
|
17
|
+
import { RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA } from './russian-trusted-ca.js';
|
|
18
|
+
/**
|
|
19
|
+
* platform-api.max.ru is shut down on 2026-07-19; platform-api2.max.ru is the
|
|
20
|
+
* canonical endpoint since June 2026.
|
|
21
|
+
*/
|
|
22
|
+
const BASE_URL = 'https://platform-api2.max.ru';
|
|
23
|
+
/**
|
|
24
|
+
* Default per-request deadline. Raised from 10s after an intermittent in-process
|
|
25
|
+
* stall aborted sends at exactly 10s ("This operation was aborted") while the
|
|
26
|
+
* same request from a fresh process completed in <300ms. The long poll sets its
|
|
27
|
+
* own (longer) timeout, so this only bounds one-shot calls like send/edit/get.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
30
|
+
/** Round trips slower than this are logged, to fingerprint a stalling path. */
|
|
31
|
+
const MAX_SLOW_REQUEST_MS = 5_000;
|
|
32
|
+
/** New connections whose DNS+TCP+TLS exceeds this are logged. */
|
|
33
|
+
const MAX_SLOW_CONNECT_MS = 2_000;
|
|
34
|
+
let testFetchOverride;
|
|
35
|
+
/**
|
|
36
|
+
* Test seam: route all MAX HTTP through the given fetch (e.g. a vi.fn or a
|
|
37
|
+
* passthrough to a mocked globalThis.fetch). Pass undefined to restore.
|
|
38
|
+
*/
|
|
39
|
+
export function setMaxFetchForTests(fetchImpl) {
|
|
40
|
+
testFetchOverride = fetchImpl;
|
|
41
|
+
}
|
|
42
|
+
let cachedDispatcher;
|
|
43
|
+
function getMaxDispatcher() {
|
|
44
|
+
if (!cachedDispatcher) {
|
|
45
|
+
const tlsWithCaList = tls;
|
|
46
|
+
// getCACertificates("default") includes NODE_EXTRA_CA_CERTS additions when available
|
|
47
|
+
const systemCas = tlsWithCaList.getCACertificates?.('default') ?? tls.rootCertificates;
|
|
48
|
+
const ca = [...systemCas, RUSSIAN_TRUSTED_ROOT_CA, RUSSIAN_TRUSTED_SUB_CA];
|
|
49
|
+
// Wrap the TLS connector to time the DNS+TCP+TLS phase, scoped to this
|
|
50
|
+
// dispatcher only (never process-wide). Diagnosing the intermittent
|
|
51
|
+
// "This operation was aborted" needs to separate a connect stall from a
|
|
52
|
+
// response-wait stall; a slow or failed connect leaves a fingerprint here.
|
|
53
|
+
const connect = buildConnector({ ca });
|
|
54
|
+
const timedConnect = (options, callback) => {
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
connect(options, (...args) => {
|
|
57
|
+
const err = args[0];
|
|
58
|
+
const elapsedMs = Date.now() - startedAt;
|
|
59
|
+
if (err || elapsedMs > MAX_SLOW_CONNECT_MS) {
|
|
60
|
+
console.error(`[MAX API] connect ${options.hostname} ${err ? 'failed' : 'slow'} dns+tcp+tls=${elapsedMs}ms` +
|
|
61
|
+
(err ? ` (${err.name})` : ''));
|
|
62
|
+
}
|
|
63
|
+
callback(...args);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
cachedDispatcher = new Agent({ connect: timedConnect });
|
|
67
|
+
}
|
|
68
|
+
return cachedDispatcher;
|
|
69
|
+
}
|
|
70
|
+
function maxFetch(url, init) {
|
|
71
|
+
if (testFetchOverride)
|
|
72
|
+
return testFetchOverride(url, init);
|
|
73
|
+
return undiciFetch(url, {
|
|
74
|
+
...init,
|
|
75
|
+
dispatcher: getMaxDispatcher(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function escapeMultipartHeaderValue(value) {
|
|
79
|
+
return value
|
|
80
|
+
.replace(/\\/g, '\\\\')
|
|
81
|
+
.replace(/"/g, '\\"')
|
|
82
|
+
.replace(/[\r\n]/g, '_');
|
|
83
|
+
}
|
|
84
|
+
function buildMultipartFileBody(fieldName, fileName, mimeType, fileBuffer) {
|
|
85
|
+
const boundary = `----openclaw-max-${randomBytes(12).toString('hex')}`;
|
|
86
|
+
const escapedField = escapeMultipartHeaderValue(fieldName);
|
|
87
|
+
const escapedFile = escapeMultipartHeaderValue(fileName);
|
|
88
|
+
const preamble = Buffer.from(`--${boundary}\r\n` +
|
|
89
|
+
`Content-Disposition: form-data; name="${escapedField}"; filename="${escapedFile}"\r\n` +
|
|
90
|
+
`Content-Type: ${mimeType}\r\n\r\n`);
|
|
91
|
+
const closing = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
92
|
+
return {
|
|
93
|
+
body: Buffer.concat([preamble, fileBuffer, closing]),
|
|
94
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** MAX docs (POST /messages): at most 2 messages per second into one dialog, chat or channel. */
|
|
98
|
+
export const MAX_SENDS_PER_CHAT_PER_SECOND = 2;
|
|
99
|
+
/**
|
|
100
|
+
* Sliding-window limiter for sends into one chat. Callers for the same key are
|
|
101
|
+
* served in order; a caller over the limit waits until the oldest send in the
|
|
102
|
+
* window is a full window old. Keys are independent, long polling and other
|
|
103
|
+
* requests never pass through it.
|
|
104
|
+
*/
|
|
105
|
+
export class MaxChatSendLimiter {
|
|
106
|
+
limit;
|
|
107
|
+
windowMs;
|
|
108
|
+
recent = new Map();
|
|
109
|
+
tails = new Map();
|
|
110
|
+
constructor(limit = MAX_SENDS_PER_CHAT_PER_SECOND, windowMs = 1000) {
|
|
111
|
+
this.limit = limit;
|
|
112
|
+
this.windowMs = windowMs;
|
|
113
|
+
}
|
|
114
|
+
acquire(key) {
|
|
115
|
+
const take = async () => {
|
|
116
|
+
for (;;) {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const times = (this.recent.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
|
119
|
+
if (times.length < this.limit) {
|
|
120
|
+
times.push(now);
|
|
121
|
+
this.recent.set(key, times);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.recent.set(key, times);
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, times[0] + this.windowMs - now));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const slot = (this.tails.get(key) ?? Promise.resolve()).then(take);
|
|
129
|
+
this.tails.set(key, slot);
|
|
130
|
+
void slot.finally(() => {
|
|
131
|
+
if (this.tails.get(key) === slot)
|
|
132
|
+
this.tails.delete(key);
|
|
133
|
+
});
|
|
134
|
+
if (this.recent.size > 1000)
|
|
135
|
+
this.prune();
|
|
136
|
+
return slot;
|
|
137
|
+
}
|
|
138
|
+
prune() {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
for (const [key, times] of this.recent) {
|
|
141
|
+
if (!this.tails.has(key) && times.every((t) => now - t >= this.windowMs))
|
|
142
|
+
this.recent.delete(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
let sharedSendLimiter = new MaxChatSendLimiter();
|
|
147
|
+
/** @internal Fresh process-wide send limiter (test isolation). */
|
|
148
|
+
export function resetMaxSendLimiterForTests() {
|
|
149
|
+
sharedSendLimiter = new MaxChatSendLimiter();
|
|
150
|
+
}
|
|
151
|
+
export class MaxApiError extends Error {
|
|
152
|
+
status;
|
|
153
|
+
body;
|
|
154
|
+
/** MAX error code from the response body, e.g. "attachment.not.ready" */
|
|
155
|
+
code;
|
|
156
|
+
/** Parsed Retry-After (ms), when the response carried one */
|
|
157
|
+
retryAfterMs;
|
|
158
|
+
constructor(message, status, body) {
|
|
159
|
+
super(message);
|
|
160
|
+
this.status = status;
|
|
161
|
+
this.body = body;
|
|
162
|
+
this.name = 'MaxApiError';
|
|
163
|
+
if (body && typeof body === 'object') {
|
|
164
|
+
const code = body.code;
|
|
165
|
+
if (typeof code === 'string')
|
|
166
|
+
this.code = code;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A request that hit the client-side deadline (our AbortController), as opposed
|
|
172
|
+
* to a caller-supplied stop signal. Carries the stalled phase so the send path
|
|
173
|
+
* can decide whether one retry on a fresh connection is safe.
|
|
174
|
+
*/
|
|
175
|
+
export class MaxRequestTimeoutError extends Error {
|
|
176
|
+
method;
|
|
177
|
+
path;
|
|
178
|
+
elapsedMs;
|
|
179
|
+
phase;
|
|
180
|
+
constructor(method, path, elapsedMs, phase) {
|
|
181
|
+
super(`MAX API ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`);
|
|
182
|
+
this.method = method;
|
|
183
|
+
this.path = path;
|
|
184
|
+
this.elapsedMs = elapsedMs;
|
|
185
|
+
this.phase = phase;
|
|
186
|
+
this.name = 'MaxRequestTimeoutError';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether an error is safe to retry. `idempotent` gates the ambiguous cases:
|
|
191
|
+
* for non-idempotent calls (POST /messages, POST /answers) a 5xx or network
|
|
192
|
+
* failure may mean the request WAS applied server-side, so retrying would
|
|
193
|
+
* duplicate the message — only a definite 429 (never applied) is retried.
|
|
194
|
+
*/
|
|
195
|
+
function isRetryableError(err, idempotent) {
|
|
196
|
+
// A client-side timeout is safe to retry only for idempotent methods here; the
|
|
197
|
+
// send path handles its own single, duplicate-safe retry for POST /messages.
|
|
198
|
+
if (err instanceof MaxRequestTimeoutError)
|
|
199
|
+
return idempotent;
|
|
200
|
+
if (err instanceof MaxApiError) {
|
|
201
|
+
if (err.status === 429)
|
|
202
|
+
return true; // rate-limited: request was rejected, safe to retry
|
|
203
|
+
// 500 excluded everywhere: the request may have been applied.
|
|
204
|
+
if (err.status === 502 || err.status === 503 || err.status === 504)
|
|
205
|
+
return idempotent;
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
209
|
+
return false;
|
|
210
|
+
// undici network-level failure (fetch failed, ECONNRESET, socket hang up…):
|
|
211
|
+
// ambiguous — the request may have reached the server, so retry only if idempotent.
|
|
212
|
+
return idempotent && (err instanceof TypeError || err instanceof Error);
|
|
213
|
+
}
|
|
214
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'PUT', 'DELETE']);
|
|
215
|
+
export class MaxApi {
|
|
216
|
+
token;
|
|
217
|
+
baseUrl;
|
|
218
|
+
timeoutMs;
|
|
219
|
+
retryAttempts;
|
|
220
|
+
sendLimiter;
|
|
221
|
+
/** Limiter key prefix: the same chat under two bots has two budgets. */
|
|
222
|
+
tokenKey;
|
|
223
|
+
constructor(opts) {
|
|
224
|
+
this.token = opts.token;
|
|
225
|
+
this.sendLimiter = opts.sendLimiter === undefined ? sharedSendLimiter : opts.sendLimiter;
|
|
226
|
+
this.tokenKey = createHash('sha256').update(opts.token).digest('hex').slice(0, 12);
|
|
227
|
+
this.baseUrl = opts.baseUrl ?? BASE_URL;
|
|
228
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
229
|
+
const envAttempts = Number(process.env.OPENCLAW_MAX_RETRY_ATTEMPTS);
|
|
230
|
+
this.retryAttempts =
|
|
231
|
+
opts.retryAttempts ?? (Number.isFinite(envAttempts) && envAttempts >= 0 ? envAttempts : 3);
|
|
232
|
+
}
|
|
233
|
+
// ── HTTP helpers ──
|
|
234
|
+
async requestOnce(method, path, params, body, timeoutMs, externalSignal) {
|
|
235
|
+
const url = new URL(path, this.baseUrl);
|
|
236
|
+
if (params) {
|
|
237
|
+
for (const [k, v] of Object.entries(params)) {
|
|
238
|
+
if (v != null)
|
|
239
|
+
url.searchParams.set(k, String(v));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
let timedOutByUs = false;
|
|
244
|
+
let gotResponse = false;
|
|
245
|
+
const startedAt = Date.now();
|
|
246
|
+
const timeout = setTimeout(() => {
|
|
247
|
+
timedOutByUs = true;
|
|
248
|
+
controller.abort();
|
|
249
|
+
}, timeoutMs ?? this.timeoutMs);
|
|
250
|
+
// A caller-supplied signal (the channel stop) must cancel the in-flight
|
|
251
|
+
// request, not just be polled between requests: a 35s long poll otherwise
|
|
252
|
+
// keeps running long after the gateway asked the channel to stop.
|
|
253
|
+
const signal = externalSignal
|
|
254
|
+
? AbortSignal.any([controller.signal, externalSignal])
|
|
255
|
+
: controller.signal;
|
|
256
|
+
try {
|
|
257
|
+
const res = await maxFetch(url.toString(), {
|
|
258
|
+
method,
|
|
259
|
+
headers: {
|
|
260
|
+
Authorization: this.token,
|
|
261
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
262
|
+
},
|
|
263
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
264
|
+
signal,
|
|
265
|
+
});
|
|
266
|
+
gotResponse = true;
|
|
267
|
+
const json = (await res.json().catch(() => null));
|
|
268
|
+
const elapsedMs = Date.now() - startedAt;
|
|
269
|
+
// The long poll blocks server-side for its `timeout` seconds by design, so
|
|
270
|
+
// it is excluded here; every other call reaching the server slowly is a
|
|
271
|
+
// fingerprint that points away from a connect hang.
|
|
272
|
+
if (elapsedMs > MAX_SLOW_REQUEST_MS && path !== '/updates') {
|
|
273
|
+
console.error(`[MAX API] ${method} ${path} slow: ${elapsedMs}ms (status ${res.status})`);
|
|
274
|
+
}
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
// Never log the request body: it may carry the webhook secret (POST
|
|
277
|
+
// /subscriptions) or private message text. Log only method+path+status.
|
|
278
|
+
console.error(`[MAX API] ${method} ${path} → ${res.status}`);
|
|
279
|
+
const error = new MaxApiError(`MAX API ${method} ${path} → ${res.status}`, res.status, json);
|
|
280
|
+
const retryAfter = res.headers?.get?.('retry-after');
|
|
281
|
+
if (retryAfter) {
|
|
282
|
+
const seconds = Number(retryAfter);
|
|
283
|
+
if (Number.isFinite(seconds) && seconds > 0)
|
|
284
|
+
error.retryAfterMs = seconds * 1000;
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
return json;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
const elapsedMs = Date.now() - startedAt;
|
|
292
|
+
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
293
|
+
if (timedOutByUs && isAbort) {
|
|
294
|
+
// Our own deadline fired (not the caller's stop signal). Convert the
|
|
295
|
+
// opaque "This operation was aborted" into a typed, loggable timeout
|
|
296
|
+
// that records whether a response had started.
|
|
297
|
+
const phase = gotResponse ? 'reading-body' : 'awaiting-response';
|
|
298
|
+
console.error(`[MAX API] ${method} ${path} timed out after ${elapsedMs}ms (phase=${phase})`);
|
|
299
|
+
throw new MaxRequestTimeoutError(method, path, elapsedMs, phase);
|
|
300
|
+
}
|
|
301
|
+
if (!isAbort && !(err instanceof MaxApiError)) {
|
|
302
|
+
// Network-level failure (DNS/TCP/TLS/reset): record timing so a connect
|
|
303
|
+
// stall is distinguishable from a response-wait stall in the log.
|
|
304
|
+
console.error(`[MAX API] ${method} ${path} failed after ${elapsedMs}ms: ${err.name}`);
|
|
305
|
+
}
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
clearTimeout(timeout);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async request(method, path, params, body, timeoutMs, retryAttempts, externalSignal) {
|
|
313
|
+
const attempts = retryAttempts ?? this.retryAttempts;
|
|
314
|
+
if (attempts <= 1) {
|
|
315
|
+
return this.requestOnce(method, path, params, body, timeoutMs, externalSignal);
|
|
316
|
+
}
|
|
317
|
+
const idempotent = IDEMPOTENT_METHODS.has(method);
|
|
318
|
+
return retryAsync(() => this.requestOnce(method, path, params, body, timeoutMs, externalSignal), {
|
|
319
|
+
attempts,
|
|
320
|
+
minDelayMs: 500,
|
|
321
|
+
maxDelayMs: 5_000,
|
|
322
|
+
label: `MAX ${method} ${path}`,
|
|
323
|
+
shouldRetry: (err) => isRetryableError(err, idempotent),
|
|
324
|
+
retryAfterMs: (err) => (err instanceof MaxApiError ? err.retryAfterMs : undefined),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
// ── Bot info ──
|
|
328
|
+
async getMe() {
|
|
329
|
+
return this.request('GET', '/me');
|
|
330
|
+
}
|
|
331
|
+
// ── Messages ──
|
|
332
|
+
async sendMessage(body, params) {
|
|
333
|
+
const chat = params.chat_id != null ? `c${params.chat_id}` : `u${params.user_id}`;
|
|
334
|
+
await this.sendLimiter?.acquire(`${this.tokenKey}:${chat}`);
|
|
335
|
+
return this.request('POST', '/messages', params, body);
|
|
336
|
+
}
|
|
337
|
+
async editMessage(messageId, body) {
|
|
338
|
+
return this.request('PUT', '/messages', { message_id: messageId }, body);
|
|
339
|
+
}
|
|
340
|
+
async deleteMessage(messageId) {
|
|
341
|
+
return this.request('DELETE', '/messages', { message_id: messageId });
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* List messages in a chat, newest first (GET /messages). `before`/`after` are
|
|
345
|
+
* Unix-time bounds in ms; they replace the deprecated `from`/`to`.
|
|
346
|
+
*/
|
|
347
|
+
async getMessages(chatId, params) {
|
|
348
|
+
const qp = { chat_id: chatId };
|
|
349
|
+
if (params?.message_ids)
|
|
350
|
+
qp.message_ids = params.message_ids.join(',');
|
|
351
|
+
if (params?.before != null)
|
|
352
|
+
qp.before = params.before;
|
|
353
|
+
if (params?.after != null)
|
|
354
|
+
qp.after = params.after;
|
|
355
|
+
if (params?.count)
|
|
356
|
+
qp.count = params.count;
|
|
357
|
+
return this.request('GET', '/messages', qp);
|
|
358
|
+
}
|
|
359
|
+
/** Get a single message by its mid (GET /messages/{messageId}); 404 if gone or inaccessible. */
|
|
360
|
+
async getMessageById(messageId) {
|
|
361
|
+
return this.request('GET', `/messages/${encodeURIComponent(messageId)}`);
|
|
362
|
+
}
|
|
363
|
+
// ── Chats ──
|
|
364
|
+
/** Get chat info by numeric id (GET /chats/{chatId}). */
|
|
365
|
+
async getChat(chatId) {
|
|
366
|
+
return this.request('GET', `/chats/${chatId}`);
|
|
367
|
+
}
|
|
368
|
+
/** Bot's own membership in a chat — is_admin matters: long polling delivers group updates only to admin bots. */
|
|
369
|
+
async getMembership(chatId) {
|
|
370
|
+
return this.request('GET', `/chats/${chatId}/members/me`);
|
|
371
|
+
}
|
|
372
|
+
// ── Pinned messages ──
|
|
373
|
+
async getPinnedMessage(chatId) {
|
|
374
|
+
return this.request('GET', `/chats/${chatId}/pin`);
|
|
375
|
+
}
|
|
376
|
+
async pinMessage(chatId, messageId, notify) {
|
|
377
|
+
return this.request('PUT', `/chats/${chatId}/pin`, undefined, {
|
|
378
|
+
message_id: messageId,
|
|
379
|
+
...(notify != null ? { notify } : {}),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async unpinMessage(chatId) {
|
|
383
|
+
return this.request('DELETE', `/chats/${chatId}/pin`);
|
|
384
|
+
}
|
|
385
|
+
// ── Chat actions ──
|
|
386
|
+
async sendAction(chatId, action) {
|
|
387
|
+
return this.request('POST', `/chats/${chatId}/actions`, undefined, { action });
|
|
388
|
+
}
|
|
389
|
+
// ── Callbacks ──
|
|
390
|
+
async answerCallback(callbackId, body) {
|
|
391
|
+
return this.request('POST', '/answers', { callback_id: callbackId }, body);
|
|
392
|
+
}
|
|
393
|
+
// ── Updates (long polling) ──
|
|
394
|
+
async getUpdates(params) {
|
|
395
|
+
const qp = {};
|
|
396
|
+
if (params?.limit)
|
|
397
|
+
qp.limit = params.limit;
|
|
398
|
+
if (params?.timeout != null)
|
|
399
|
+
qp.timeout = params.timeout;
|
|
400
|
+
if (params?.marker != null)
|
|
401
|
+
qp.marker = params.marker;
|
|
402
|
+
if (params?.types?.length)
|
|
403
|
+
qp.types = params.types.join(',');
|
|
404
|
+
// Long polling needs a longer timeout; the polling loop owns retries
|
|
405
|
+
const pollTimeout = ((params?.timeout ?? 30) + 5) * 1000;
|
|
406
|
+
return this.request('GET', '/updates', qp, undefined, pollTimeout, 0, params?.signal);
|
|
407
|
+
}
|
|
408
|
+
// ── Videos ──
|
|
409
|
+
/** Playback info for an inbound video attachment (GET /videos/{videoToken}). */
|
|
410
|
+
async getVideoInfo(videoToken) {
|
|
411
|
+
return this.request('GET', `/videos/${encodeURIComponent(videoToken)}`);
|
|
412
|
+
}
|
|
413
|
+
// ── Subscriptions (webhooks) ──
|
|
414
|
+
async getSubscriptions() {
|
|
415
|
+
return this.request('GET', '/subscriptions');
|
|
416
|
+
}
|
|
417
|
+
async subscribe(params) {
|
|
418
|
+
return this.request('POST', '/subscriptions', undefined, params);
|
|
419
|
+
}
|
|
420
|
+
async unsubscribe(subscriptionUrl) {
|
|
421
|
+
return this.request('DELETE', '/subscriptions', { url: subscriptionUrl });
|
|
422
|
+
}
|
|
423
|
+
// ── Commands ──
|
|
424
|
+
/**
|
|
425
|
+
* Replace the bot command list (PATCH /me/commands; PATCH /me answers 404).
|
|
426
|
+
* Limits: 32 commands, name ≤ 64 chars (no leading slash), description ≤ 128 chars.
|
|
427
|
+
*/
|
|
428
|
+
async setMyCommands(commands) {
|
|
429
|
+
const normalized = commands
|
|
430
|
+
.map((cmd) => ({
|
|
431
|
+
name: cmd.name.replace(/^\//, '').slice(0, 64),
|
|
432
|
+
...(cmd.description ? { description: cmd.description.slice(0, 128) } : {}),
|
|
433
|
+
}))
|
|
434
|
+
.filter((cmd) => cmd.name.length > 0)
|
|
435
|
+
.slice(0, 32);
|
|
436
|
+
return this.request('PATCH', '/me/commands', undefined, {
|
|
437
|
+
commands: normalized,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// ── Upload ──
|
|
441
|
+
async getUploadUrl(type) {
|
|
442
|
+
return this.request('POST', `/uploads`, { type });
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Upload media file to MAX.
|
|
446
|
+
* MAX upload requires multipart/form-data with field "data".
|
|
447
|
+
* Response contains a token (in photos/videos/etc) to use in attachments.
|
|
448
|
+
*
|
|
449
|
+
* @param type Media type (image, video, audio, file)
|
|
450
|
+
* @param data File path (string) or buffer (Buffer/Uint8Array)
|
|
451
|
+
* @param contentType MIME type (optional, auto-detected for common types)
|
|
452
|
+
* @returns Upload result with token (for use in attachment payload)
|
|
453
|
+
*/
|
|
454
|
+
async uploadMedia(type, data, contentType) {
|
|
455
|
+
// Step 1: Get upload URL and attachment token.
|
|
456
|
+
// MAX returns the attachment token together with the upload URL. The
|
|
457
|
+
// following upload host may answer with XML like `<retval>1</retval>` and
|
|
458
|
+
// does not repeat that token.
|
|
459
|
+
const uploadInfo = await this.getUploadUrl(type);
|
|
460
|
+
const uploadUrl = uploadInfo.url;
|
|
461
|
+
// Step 2: Load file if data is a path
|
|
462
|
+
let fileBuffer;
|
|
463
|
+
let mimeType = contentType;
|
|
464
|
+
let fileName = 'file';
|
|
465
|
+
if (typeof data === 'string') {
|
|
466
|
+
// File path — read from disk
|
|
467
|
+
const fs = await import('fs/promises');
|
|
468
|
+
const path = await import('path');
|
|
469
|
+
fileBuffer = await fs.readFile(data);
|
|
470
|
+
fileName = path.basename(data);
|
|
471
|
+
// Auto-detect MIME type if not provided
|
|
472
|
+
if (!mimeType) {
|
|
473
|
+
const ext = data.split('.').pop()?.toLowerCase();
|
|
474
|
+
const mimeMap = {
|
|
475
|
+
jpg: 'image/jpeg',
|
|
476
|
+
jpeg: 'image/jpeg',
|
|
477
|
+
png: 'image/png',
|
|
478
|
+
gif: 'image/gif',
|
|
479
|
+
webp: 'image/webp',
|
|
480
|
+
heic: 'image/heic',
|
|
481
|
+
heif: 'image/heif',
|
|
482
|
+
tif: 'image/tiff',
|
|
483
|
+
tiff: 'image/tiff',
|
|
484
|
+
bmp: 'image/bmp',
|
|
485
|
+
mp4: 'video/mp4',
|
|
486
|
+
mov: 'video/quicktime',
|
|
487
|
+
avi: 'video/x-msvideo',
|
|
488
|
+
mkv: 'video/x-matroska',
|
|
489
|
+
webm: 'video/webm',
|
|
490
|
+
mp3: 'audio/mpeg',
|
|
491
|
+
wav: 'audio/wav',
|
|
492
|
+
ogg: 'audio/ogg',
|
|
493
|
+
m4a: 'audio/mp4',
|
|
494
|
+
aac: 'audio/aac',
|
|
495
|
+
flac: 'audio/flac',
|
|
496
|
+
opus: 'audio/opus',
|
|
497
|
+
pdf: 'application/pdf',
|
|
498
|
+
txt: 'text/plain',
|
|
499
|
+
};
|
|
500
|
+
mimeType = ext ? mimeMap[ext] : 'application/octet-stream';
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// Already a buffer
|
|
505
|
+
fileBuffer = Buffer.from(data);
|
|
506
|
+
mimeType = mimeType ?? 'application/octet-stream';
|
|
507
|
+
}
|
|
508
|
+
// Step 3: POST file as multipart/form-data to upload URL. MAX upload hosts
|
|
509
|
+
// reject Node's native FormData in some cases with 412, while curl-style
|
|
510
|
+
// multipart with a known Content-Length is accepted.
|
|
511
|
+
const multipart = buildMultipartFileBody('data', fileName, mimeType, fileBuffer);
|
|
512
|
+
const uploadRes = await maxFetch(uploadUrl, {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: {
|
|
515
|
+
'Content-Type': multipart.contentType,
|
|
516
|
+
'Content-Length': String(multipart.body.byteLength),
|
|
517
|
+
},
|
|
518
|
+
body: multipart.body,
|
|
519
|
+
});
|
|
520
|
+
if (!uploadRes.ok) {
|
|
521
|
+
throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}`, uploadRes.status, await uploadRes.text().catch(() => null));
|
|
522
|
+
}
|
|
523
|
+
// Step 4: Parse response to extract token
|
|
524
|
+
// Image response: { photos: { "<id>": { token: "...", url: "..." } } }
|
|
525
|
+
// Video/audio/file may respond with a top-level token or a similar nested map
|
|
526
|
+
const result = (await uploadRes.json().catch(() => ({})));
|
|
527
|
+
// Try to find token from nested response structure
|
|
528
|
+
let token = typeof uploadInfo.token === 'string' ? uploadInfo.token : '';
|
|
529
|
+
let url;
|
|
530
|
+
let foundNested = false;
|
|
531
|
+
const nestedContainers = ['photos', 'videos', 'audios', 'files'];
|
|
532
|
+
for (const key of nestedContainers) {
|
|
533
|
+
const container = result[key];
|
|
534
|
+
if (container && typeof container === 'object' && !Array.isArray(container)) {
|
|
535
|
+
const first = Object.values(container)[0];
|
|
536
|
+
if (first?.token) {
|
|
537
|
+
token = first.token;
|
|
538
|
+
url = first.url;
|
|
539
|
+
foundNested = true;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!foundNested && result.token && typeof result.token === 'string') {
|
|
545
|
+
token = result.token;
|
|
546
|
+
url = result.url ?? undefined;
|
|
547
|
+
}
|
|
548
|
+
if (!token) {
|
|
549
|
+
throw new MaxApiError('MAX media upload: no token in response', 200, result);
|
|
550
|
+
}
|
|
551
|
+
return { token, url };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button presses (message_callback): presentation approvals and ask_user
|
|
3
|
+
* answers go to the gateway runtimes; other payloads re-enter the inbound
|
|
4
|
+
* pipeline as a synthesized message.
|
|
5
|
+
*/
|
|
6
|
+
import { resolveApprovalOverGateway } from 'openclaw/plugin-sdk/approval-gateway-runtime';
|
|
7
|
+
import { questionGatewayRuntime } from 'openclaw/plugin-sdk/question-gateway-runtime';
|
|
8
|
+
import { processIncomingMessage } from './inbound.js';
|
|
9
|
+
import { decodeMaxPresentationCallback } from './presentation.js';
|
|
10
|
+
/**
|
|
11
|
+
* Synthesize a regular message from a button press. The keyboard's message is
|
|
12
|
+
* a sibling of `callback` in the update; its recipient is the real chat
|
|
13
|
+
* (dialog chat_id ≠ user_id). Only when that message is gone (null) do we fall
|
|
14
|
+
* back to addressing the pressing user.
|
|
15
|
+
* callback_id is not a valid MAX message id, so replies to callback-originated
|
|
16
|
+
* commands must not use it as replyToMessageId.
|
|
17
|
+
* @internal - Exported for testing only
|
|
18
|
+
*/
|
|
19
|
+
export function buildCallbackMessage(callback, message, text = callback.payload ?? '') {
|
|
20
|
+
return {
|
|
21
|
+
__maxCallback: true,
|
|
22
|
+
sender: callback.user,
|
|
23
|
+
recipient: message ? message.recipient : { chat_id: callback.user.user_id },
|
|
24
|
+
timestamp: callback.timestamp,
|
|
25
|
+
body: {
|
|
26
|
+
mid: callback.callback_id,
|
|
27
|
+
text,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function processCallback(callback, message, userLocale, opts) {
|
|
32
|
+
const payload = callback.payload ?? '';
|
|
33
|
+
if (!payload.trim())
|
|
34
|
+
return;
|
|
35
|
+
// Presentation buttons carry private envelopes (see presentation.ts).
|
|
36
|
+
// Command buttons carry the command text itself and take the default path;
|
|
37
|
+
// plain channelData.max.buttons payloads keep arriving as message text.
|
|
38
|
+
const presentationCallback = decodeMaxPresentationCallback(payload);
|
|
39
|
+
if (presentationCallback?.kind === 'approval' || presentationCallback?.kind === 'question') {
|
|
40
|
+
await resolveMaxRuntimeControlCallback(presentationCallback, callback, opts);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Opaque callback data goes to the agent labelled, never as a slash command.
|
|
44
|
+
const text = presentationCallback?.kind === 'callback'
|
|
45
|
+
? `callback_data: ${presentationCallback.value}`
|
|
46
|
+
: payload;
|
|
47
|
+
await processIncomingMessage(buildCallbackMessage(callback, message, text), userLocale, opts);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Approvals and ask_user answers are operator actions: only senders listed
|
|
51
|
+
* explicitly in the account's allowFrom may press them (a wildcard is enough
|
|
52
|
+
* for questions, never for approvals).
|
|
53
|
+
*/
|
|
54
|
+
function isMaxRuntimeControlSender(account, senderId, kind) {
|
|
55
|
+
const allowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry).trim().replace(/^max:/i, ''));
|
|
56
|
+
if (allowFrom.includes(senderId))
|
|
57
|
+
return true;
|
|
58
|
+
return kind === 'question' && allowFrom.includes('*');
|
|
59
|
+
}
|
|
60
|
+
async function resolveMaxRuntimeControlCallback(action, callback, opts) {
|
|
61
|
+
const { account, config, log } = opts;
|
|
62
|
+
const senderId = String(callback.user.user_id);
|
|
63
|
+
let notification;
|
|
64
|
+
if (!isMaxRuntimeControlSender(account, senderId, action.kind)) {
|
|
65
|
+
log?.warn(`[${account.accountId}] MAX ${action.kind} button pressed by unauthorized sender ${senderId}`);
|
|
66
|
+
notification = 'You are not allowed to answer this.';
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
if (action.kind === 'approval') {
|
|
71
|
+
const result = await resolveApprovalOverGateway({
|
|
72
|
+
cfg: config,
|
|
73
|
+
approvalId: action.approvalId,
|
|
74
|
+
approvalKind: action.approvalKind,
|
|
75
|
+
decision: action.decision,
|
|
76
|
+
channel: 'max',
|
|
77
|
+
accountId: account.accountId,
|
|
78
|
+
senderId,
|
|
79
|
+
});
|
|
80
|
+
notification = result.applied
|
|
81
|
+
? `Decision recorded: ${action.decision}.`
|
|
82
|
+
: 'This approval was already resolved.';
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const result = await questionGatewayRuntime.resolveOption({
|
|
86
|
+
cfg: config,
|
|
87
|
+
questionId: action.questionId,
|
|
88
|
+
optionValue: action.optionValue,
|
|
89
|
+
senderId,
|
|
90
|
+
authorize: () => isMaxRuntimeControlSender(account, senderId, 'question'),
|
|
91
|
+
});
|
|
92
|
+
notification =
|
|
93
|
+
result.status === 'answered'
|
|
94
|
+
? 'Answer recorded.'
|
|
95
|
+
: result.status === 'denied'
|
|
96
|
+
? 'You are not allowed to answer this.'
|
|
97
|
+
: 'This question is no longer open.';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
log?.error(`[${account.accountId}] MAX ${action.kind} callback failed: ${String(err)}`);
|
|
102
|
+
notification = 'Could not apply this action.';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await opts.api.answerCallback(callback.callback_id, { notification });
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
log?.debug?.(`[${account.accountId}] MAX callback answer failed: ${String(err)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|