@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 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.0. Изменения: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md).
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.0. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md) (in Russian).
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
- getMaxLogger().error(`[MAX API] ${method} ${path} → ${res.status}`);
330
- const error = new MaxApiError(`MAX API ${method} ${path} → ${res.status}`, res.status, json);
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
- throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}`, uploadRes.status, await uploadRes.text().catch(() => null));
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.accountId ?? DEFAULT_ACCOUNT_ID;
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: account.config.dmPolicy ?? 'pairing',
83
- allowFrom: account.config.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 = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
105
+ const groupPolicy = config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
93
106
  if (groupPolicy !== 'open') {
94
107
  return [];
95
108
  }
96
- const groupAllowlistConfigured = account.config.groups && Object.keys(account.config.groups).length > 0;
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.`,
@@ -42,8 +42,9 @@ export function toMaxDeliveryError(err, visible) {
42
42
  return err;
43
43
  }
44
44
  function logSendFailure(account, log, what, err) {
45
- const body = err?.body;
46
- log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
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;
@@ -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.0",
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.0",
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
- getMaxLogger().error(`[MAX API] ${method} ${path} → ${res.status}`);
467
- const error = new MaxApiError(
468
- `MAX API ${method} ${path} → ${res.status}`,
469
- res.status,
470
- json,
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
- await uploadRes.text().catch(() => null),
809
+ text,
776
810
  );
777
811
  }
778
812
 
@@ -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.accountId ?? DEFAULT_ACCOUNT_ID;
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: account.config.dmPolicy ?? 'pairing',
126
- allowFrom: account.config.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 = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
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
- const body = (err as { body?: unknown })?.body;
76
- log?.error(
77
- `[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
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: {