@aspalagin/openclaw-max 0.8.0 → 0.8.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/README.md +3 -1
- package/README_EN.md +3 -1
- package/assets/icon.png +0 -0
- package/dist/src/api.js +43 -4
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/deliver.js +3 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/api.ts +43 -9
- package/src/channel-policy.ts +27 -7
- package/src/deliver.ts +3 -4
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Плагин нужен тем, кто пользуется OpenClaw и хочет, чтобы ассистент был доступен в MAX. Понадобятся токен бота MAX (ботов создают организации на [business.max.ru](https://business.max.ru/self)) и собственный gateway OpenClaw.
|
|
8
8
|
|
|
9
|
-
Версия 0.8.
|
|
9
|
+
Версия 0.8.2. Изменения: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md).
|
|
10
10
|
|
|
11
11
|
## Содержание
|
|
12
12
|
|
|
@@ -490,3 +490,5 @@ CI также запускает `typecheck` и тесты на `openclaw@latest
|
|
|
490
490
|
Разработка ведётся с помощью ИИ-агентов.
|
|
491
491
|
|
|
492
492
|
**Лицензия:** MIT, см. [LICENSE](LICENSE).
|
|
493
|
+
|
|
494
|
+
**Товарный знак.** Плагин неофициальный. MAX — товарный знак правообладателя, ООО «МАХ»; иконка пакета — логотип из [брендбука MAX](https://go.max.ru/brandbook).
|
package/README_EN.md
CHANGED
|
@@ -6,7 +6,7 @@ A channel plugin that connects an [OpenClaw](https://openclaw.ai) assistant to t
|
|
|
6
6
|
|
|
7
7
|
It is for OpenClaw users who need their assistant reachable in MAX. You need a MAX bot token (bots are created by organisations on [business.max.ru](https://business.max.ru/self)) and an OpenClaw gateway you run yourself.
|
|
8
8
|
|
|
9
|
-
Version 0.8.
|
|
9
|
+
Version 0.8.2. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md) (in Russian).
|
|
10
10
|
|
|
11
11
|
## Contents
|
|
12
12
|
|
|
@@ -490,3 +490,5 @@ Questions and proposals: [GitHub Issues](https://github.com/aspalagin/openclaw-m
|
|
|
490
490
|
Development is carried out with the help of AI agents.
|
|
491
491
|
|
|
492
492
|
**License:** MIT, see [LICENSE](LICENSE).
|
|
493
|
+
|
|
494
|
+
**Trademark.** This plugin is unofficial. MAX is a trademark of its rights holder, ООО «МАХ»; the package icon is the logo from the [MAX brand book](https://go.max.ru/brandbook).
|
package/assets/icon.png
CHANGED
|
Binary file
|
package/dist/src/api.js
CHANGED
|
@@ -200,6 +200,40 @@ export class MaxApiError extends Error {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
/** A MAX error code as it may appear in the log: a short dotted identifier. */
|
|
204
|
+
const LOGGABLE_ERROR_CODE = /^[A-Za-z0-9][\w.-]{0,63}$/;
|
|
205
|
+
/** A localization key MAX puts in `message`, e.g. "Key: errors.process.attachment.movie.not.owner". */
|
|
206
|
+
const LOGGABLE_ERROR_KEY = /^Key:\s*(errors\.[\w.-]{1,100})$/;
|
|
207
|
+
/**
|
|
208
|
+
* The machine-readable part of a MAX error response for the log: `code`
|
|
209
|
+
* (e.g. "proto.payload") and, when `message` is a bare localization key, that
|
|
210
|
+
* key. Free-form `message` text is left out: MAX documents it only as a
|
|
211
|
+
* human-readable description and some messages quote the request (e.g. "Path
|
|
212
|
+
* /chats is not recognized"), so it may echo user data. Accepts the parsed
|
|
213
|
+
* body or the raw response text; an empty or non-JSON body yields "".
|
|
214
|
+
* @internal exported for testing.
|
|
215
|
+
*/
|
|
216
|
+
export function describeMaxErrorBody(body) {
|
|
217
|
+
let parsed = body;
|
|
218
|
+
if (typeof body === 'string') {
|
|
219
|
+
try {
|
|
220
|
+
parsed = JSON.parse(body);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return '';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!parsed || typeof parsed !== 'object')
|
|
227
|
+
return '';
|
|
228
|
+
const { code, message } = parsed;
|
|
229
|
+
const parts = [];
|
|
230
|
+
if (typeof code === 'string' && LOGGABLE_ERROR_CODE.test(code))
|
|
231
|
+
parts.push(`code=${code}`);
|
|
232
|
+
const key = typeof message === 'string' ? LOGGABLE_ERROR_KEY.exec(message.trim())?.[1] : undefined;
|
|
233
|
+
if (key)
|
|
234
|
+
parts.push(`key=${key}`);
|
|
235
|
+
return parts.join(' ');
|
|
236
|
+
}
|
|
203
237
|
/**
|
|
204
238
|
* A request that hit the client-side deadline (our AbortController), as opposed
|
|
205
239
|
* to a caller-supplied stop signal. Carries the stalled phase so the send path
|
|
@@ -325,9 +359,12 @@ export class MaxApi {
|
|
|
325
359
|
}
|
|
326
360
|
if (!res.ok) {
|
|
327
361
|
// Never log the request body: it may carry the webhook secret (POST
|
|
328
|
-
// /subscriptions) or private message text. Log only method+path+status
|
|
329
|
-
|
|
330
|
-
const
|
|
362
|
+
// /subscriptions) or private message text. Log only method+path+status
|
|
363
|
+
// and the MAX error code (describeMaxErrorBody).
|
|
364
|
+
const detail = describeMaxErrorBody(json);
|
|
365
|
+
const summary = `${method} ${path} → ${res.status}${detail ? ` (${detail})` : ''}`;
|
|
366
|
+
getMaxLogger().error(`[MAX API] ${summary}`);
|
|
367
|
+
const error = new MaxApiError(`MAX API ${summary}`, res.status, json);
|
|
331
368
|
const retryAfter = res.headers?.get?.('retry-after');
|
|
332
369
|
if (retryAfter) {
|
|
333
370
|
const seconds = Number(retryAfter);
|
|
@@ -534,7 +571,9 @@ export class MaxApi {
|
|
|
534
571
|
body: multipart.body,
|
|
535
572
|
}, this.network().proxyUrl);
|
|
536
573
|
if (!uploadRes.ok) {
|
|
537
|
-
|
|
574
|
+
const text = await uploadRes.text().catch(() => null);
|
|
575
|
+
const detail = describeMaxErrorBody(text);
|
|
576
|
+
throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}${detail ? ` (${detail})` : ''}`, uploadRes.status, text);
|
|
538
577
|
}
|
|
539
578
|
// Step 3: Parse response to extract token
|
|
540
579
|
// Image response: { photos: { "<id>": { token: "...", url: "..." } } }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { resolveToolsBySender } from 'openclaw/plugin-sdk/channel-policy';
|
|
6
6
|
import { PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk/channel-status';
|
|
7
7
|
import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from 'openclaw/plugin-sdk/core';
|
|
8
|
-
import { resolveMaxAccount } from './accounts.js';
|
|
8
|
+
import { readMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
9
9
|
import { sendMaxMessage } from './send.js';
|
|
10
10
|
// ── MAX group policy helpers ──
|
|
11
11
|
// These mirror resolveChannelGroupRequireMention/resolveChannelGroupToolsPolicy
|
|
@@ -69,18 +69,30 @@ function resolveMaxGroupToolPolicy(params) {
|
|
|
69
69
|
return defaultConfig.tools;
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Account config for the security adapter. Since OpenClaw 2026.9.7 `security audit`
|
|
74
|
+
* (and doctor) hand the adapter the read-only `config.inspectAccount` status, which
|
|
75
|
+
* carries no `config` — re-read the account section from cfg (side-effect free) in that case.
|
|
76
|
+
*/
|
|
77
|
+
function resolveMaxPolicyAccountConfig(cfg, accountId, account) {
|
|
78
|
+
const config = account?.config;
|
|
79
|
+
if (config && typeof config === 'object')
|
|
80
|
+
return config;
|
|
81
|
+
return readMaxAccount({ cfg, accountId }).config;
|
|
82
|
+
}
|
|
72
83
|
/** DM policy (default pairing) and warnings for open group policy. */
|
|
73
84
|
export const maxSecurityAdapter = {
|
|
74
85
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
75
|
-
const resolvedAccountId = accountId ?? account
|
|
86
|
+
const resolvedAccountId = accountId ?? account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
87
|
+
const config = resolveMaxPolicyAccountConfig(cfg, resolvedAccountId, account);
|
|
76
88
|
const maxSection = cfg.channels?.max;
|
|
77
89
|
const useAccountPath = Boolean(maxSection?.accounts?.[resolvedAccountId]);
|
|
78
90
|
const basePath = useAccountPath
|
|
79
91
|
? `channels.max.accounts.${resolvedAccountId}.`
|
|
80
92
|
: 'channels.max.';
|
|
81
93
|
return {
|
|
82
|
-
policy:
|
|
83
|
-
allowFrom:
|
|
94
|
+
policy: config.dmPolicy ?? 'pairing',
|
|
95
|
+
allowFrom: config.allowFrom ?? [],
|
|
84
96
|
policyPath: `${basePath}dmPolicy`,
|
|
85
97
|
allowFromPath: basePath,
|
|
86
98
|
approveHint: formatPairingApproveHint('max'),
|
|
@@ -88,12 +100,13 @@ export const maxSecurityAdapter = {
|
|
|
88
100
|
};
|
|
89
101
|
},
|
|
90
102
|
collectWarnings: ({ account, cfg }) => {
|
|
103
|
+
const config = resolveMaxPolicyAccountConfig(cfg, account?.accountId ?? DEFAULT_ACCOUNT_ID, account);
|
|
91
104
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
92
|
-
const groupPolicy =
|
|
105
|
+
const groupPolicy = config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
|
93
106
|
if (groupPolicy !== 'open') {
|
|
94
107
|
return [];
|
|
95
108
|
}
|
|
96
|
-
const groupAllowlistConfigured =
|
|
109
|
+
const groupAllowlistConfigured = config.groups && Object.keys(config.groups).length > 0;
|
|
97
110
|
if (groupAllowlistConfigured) {
|
|
98
111
|
return [
|
|
99
112
|
`- MAX groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.max.groupPolicy="allowlist" + channels.max.groupAllowFrom to restrict senders.`,
|
package/dist/src/deliver.js
CHANGED
|
@@ -42,8 +42,9 @@ export function toMaxDeliveryError(err, visible) {
|
|
|
42
42
|
return err;
|
|
43
43
|
}
|
|
44
44
|
function logSendFailure(account, log, what, err) {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// A MaxApiError message already carries the MAX error code; the response body
|
|
46
|
+
// stays out of the log, its free-form `message` may echo user data.
|
|
47
|
+
log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}`);
|
|
47
48
|
}
|
|
48
49
|
export async function deliverMaxReply(params) {
|
|
49
50
|
const { payload, account, chatId, config, log, statusSink } = params;
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-max",
|
|
3
3
|
"name": "MAX",
|
|
4
4
|
"description": "MAX messenger (max.ru) channel for OpenClaw: private and group chats over webhook or long polling; media, voice messages with transcription and voice replies; inline keyboards and command menus; message edits and pins; several bot accounts",
|
|
5
|
-
"version": "0.8.
|
|
5
|
+
"version": "0.8.2",
|
|
6
6
|
"categories": [
|
|
7
7
|
"channels"
|
|
8
8
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspalagin/openclaw-max",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "MAX messenger (max.ru) channel for OpenClaw: private and group chats over webhook or long polling; media, voice messages with transcription and voice replies; inline keyboards and command menus; message edits and pins; several bot accounts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
package/src/api.ts
CHANGED
|
@@ -320,6 +320,39 @@ export class MaxApiError extends Error {
|
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/** A MAX error code as it may appear in the log: a short dotted identifier. */
|
|
324
|
+
const LOGGABLE_ERROR_CODE = /^[A-Za-z0-9][\w.-]{0,63}$/;
|
|
325
|
+
/** A localization key MAX puts in `message`, e.g. "Key: errors.process.attachment.movie.not.owner". */
|
|
326
|
+
const LOGGABLE_ERROR_KEY = /^Key:\s*(errors\.[\w.-]{1,100})$/;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The machine-readable part of a MAX error response for the log: `code`
|
|
330
|
+
* (e.g. "proto.payload") and, when `message` is a bare localization key, that
|
|
331
|
+
* key. Free-form `message` text is left out: MAX documents it only as a
|
|
332
|
+
* human-readable description and some messages quote the request (e.g. "Path
|
|
333
|
+
* /chats is not recognized"), so it may echo user data. Accepts the parsed
|
|
334
|
+
* body or the raw response text; an empty or non-JSON body yields "".
|
|
335
|
+
* @internal exported for testing.
|
|
336
|
+
*/
|
|
337
|
+
export function describeMaxErrorBody(body: unknown): string {
|
|
338
|
+
let parsed = body;
|
|
339
|
+
if (typeof body === 'string') {
|
|
340
|
+
try {
|
|
341
|
+
parsed = JSON.parse(body);
|
|
342
|
+
} catch {
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!parsed || typeof parsed !== 'object') return '';
|
|
347
|
+
const { code, message } = parsed as { code?: unknown; message?: unknown };
|
|
348
|
+
const parts: string[] = [];
|
|
349
|
+
if (typeof code === 'string' && LOGGABLE_ERROR_CODE.test(code)) parts.push(`code=${code}`);
|
|
350
|
+
const key =
|
|
351
|
+
typeof message === 'string' ? LOGGABLE_ERROR_KEY.exec(message.trim())?.[1] : undefined;
|
|
352
|
+
if (key) parts.push(`key=${key}`);
|
|
353
|
+
return parts.join(' ');
|
|
354
|
+
}
|
|
355
|
+
|
|
323
356
|
/**
|
|
324
357
|
* A request that hit the client-side deadline (our AbortController), as opposed
|
|
325
358
|
* to a caller-supplied stop signal. Carries the stalled phase so the send path
|
|
@@ -462,13 +495,12 @@ export class MaxApi {
|
|
|
462
495
|
|
|
463
496
|
if (!res.ok) {
|
|
464
497
|
// Never log the request body: it may carry the webhook secret (POST
|
|
465
|
-
// /subscriptions) or private message text. Log only method+path+status
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
);
|
|
498
|
+
// /subscriptions) or private message text. Log only method+path+status
|
|
499
|
+
// and the MAX error code (describeMaxErrorBody).
|
|
500
|
+
const detail = describeMaxErrorBody(json);
|
|
501
|
+
const summary = `${method} ${path} → ${res.status}${detail ? ` (${detail})` : ''}`;
|
|
502
|
+
getMaxLogger().error(`[MAX API] ${summary}`);
|
|
503
|
+
const error = new MaxApiError(`MAX API ${summary}`, res.status, json);
|
|
472
504
|
const retryAfter = res.headers?.get?.('retry-after');
|
|
473
505
|
if (retryAfter) {
|
|
474
506
|
const seconds = Number(retryAfter);
|
|
@@ -769,10 +801,12 @@ export class MaxApi {
|
|
|
769
801
|
);
|
|
770
802
|
|
|
771
803
|
if (!uploadRes.ok) {
|
|
804
|
+
const text = await uploadRes.text().catch(() => null);
|
|
805
|
+
const detail = describeMaxErrorBody(text);
|
|
772
806
|
throw new MaxApiError(
|
|
773
|
-
`MAX media upload failed: ${uploadRes.status}`,
|
|
807
|
+
`MAX media upload failed: ${uploadRes.status}${detail ? ` (${detail})` : ''}`,
|
|
774
808
|
uploadRes.status,
|
|
775
|
-
|
|
809
|
+
text,
|
|
776
810
|
);
|
|
777
811
|
}
|
|
778
812
|
|
package/src/channel-policy.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk/channel-status';
|
|
|
10
10
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
11
11
|
import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from 'openclaw/plugin-sdk/core';
|
|
12
12
|
|
|
13
|
-
import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
13
|
+
import { readMaxAccount, type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
14
14
|
import { sendMaxMessage } from './send.js';
|
|
15
15
|
|
|
16
16
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
@@ -109,10 +109,26 @@ function resolveMaxGroupToolPolicy(params: {
|
|
|
109
109
|
return undefined;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Account config for the security adapter. Since OpenClaw 2026.9.7 `security audit`
|
|
114
|
+
* (and doctor) hand the adapter the read-only `config.inspectAccount` status, which
|
|
115
|
+
* carries no `config` — re-read the account section from cfg (side-effect free) in that case.
|
|
116
|
+
*/
|
|
117
|
+
function resolveMaxPolicyAccountConfig(
|
|
118
|
+
cfg: OpenClawConfig,
|
|
119
|
+
accountId: string,
|
|
120
|
+
account: Pick<ResolvedMaxAccount, 'config'> | { config?: undefined } | null | undefined,
|
|
121
|
+
): ResolvedMaxAccount['config'] {
|
|
122
|
+
const config = (account as { config?: ResolvedMaxAccount['config'] } | null | undefined)?.config;
|
|
123
|
+
if (config && typeof config === 'object') return config;
|
|
124
|
+
return readMaxAccount({ cfg, accountId }).config;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/** DM policy (default pairing) and warnings for open group policy. */
|
|
113
128
|
export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
114
129
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
115
|
-
const resolvedAccountId = accountId ?? account
|
|
130
|
+
const resolvedAccountId = accountId ?? account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
131
|
+
const config = resolveMaxPolicyAccountConfig(cfg, resolvedAccountId, account);
|
|
116
132
|
const maxSection = (cfg.channels as Record<string, unknown>)?.max as
|
|
117
133
|
Record<string, unknown> | undefined;
|
|
118
134
|
const useAccountPath = Boolean(
|
|
@@ -122,8 +138,8 @@ export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
|
122
138
|
? `channels.max.accounts.${resolvedAccountId}.`
|
|
123
139
|
: 'channels.max.';
|
|
124
140
|
return {
|
|
125
|
-
policy:
|
|
126
|
-
allowFrom:
|
|
141
|
+
policy: config.dmPolicy ?? 'pairing',
|
|
142
|
+
allowFrom: config.allowFrom ?? [],
|
|
127
143
|
policyPath: `${basePath}dmPolicy`,
|
|
128
144
|
allowFromPath: basePath,
|
|
129
145
|
approveHint: formatPairingApproveHint('max'),
|
|
@@ -131,13 +147,17 @@ export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
|
131
147
|
};
|
|
132
148
|
},
|
|
133
149
|
collectWarnings: ({ account, cfg }) => {
|
|
150
|
+
const config = resolveMaxPolicyAccountConfig(
|
|
151
|
+
cfg,
|
|
152
|
+
account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
153
|
+
account,
|
|
154
|
+
);
|
|
134
155
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
135
|
-
const groupPolicy =
|
|
156
|
+
const groupPolicy = config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
|
136
157
|
if (groupPolicy !== 'open') {
|
|
137
158
|
return [];
|
|
138
159
|
}
|
|
139
|
-
const groupAllowlistConfigured =
|
|
140
|
-
account.config.groups && Object.keys(account.config.groups).length > 0;
|
|
160
|
+
const groupAllowlistConfigured = config.groups && Object.keys(config.groups).length > 0;
|
|
141
161
|
if (groupAllowlistConfigured) {
|
|
142
162
|
return [
|
|
143
163
|
`- MAX groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.max.groupPolicy="allowlist" + channels.max.groupAllowFrom to restrict senders.`,
|
package/src/deliver.ts
CHANGED
|
@@ -72,10 +72,9 @@ function logSendFailure(
|
|
|
72
72
|
what: string,
|
|
73
73
|
err: unknown,
|
|
74
74
|
): void {
|
|
75
|
-
|
|
76
|
-
log
|
|
77
|
-
|
|
78
|
-
);
|
|
75
|
+
// A MaxApiError message already carries the MAX error code; the response body
|
|
76
|
+
// stays out of the log, its free-form `message` may echo user data.
|
|
77
|
+
log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}`);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
export async function deliverMaxReply(params: {
|