@aspalagin/openclaw-max 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
package/package.json CHANGED
@@ -1,16 +1,28 @@
1
1
  {
2
2
  "name": "@aspalagin/openclaw-max",
3
- "version": "0.7.2",
4
- "description": "MAX messenger channel plugin for OpenClaw",
3
+ "version": "0.8.0",
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",
7
7
  "openclaw-plugin",
8
+ "openclaw-channel",
8
9
  "max",
10
+ "max.ru",
9
11
  "max-messenger",
12
+ "max-bot-api",
10
13
  "messenger",
14
+ "chatbot",
15
+ "bot",
16
+ "webhook",
17
+ "long-polling",
18
+ "voice-messages",
11
19
  "ai-agent"
12
20
  ],
13
21
  "license": "MIT",
22
+ "author": {
23
+ "name": "Arseniy Palagin",
24
+ "url": "https://github.com/aspalagin"
25
+ },
14
26
  "engines": {
15
27
  "node": ">=22"
16
28
  },
@@ -25,7 +37,7 @@
25
37
  "url": "https://github.com/aspalagin/openclaw-max/issues"
26
38
  },
27
39
  "scripts": {
28
- "build": "rm -rf dist-ts dist/src && tsc --noEmit false --noEmitOnError false --outDir dist-ts && mkdir -p dist/src && cp dist-ts/index.js dist-ts/setup-entry.js dist/ && cp dist-ts/src/*.js dist/src/ && rm -f dist/src/*.test.js dist/src/test-setup.js",
40
+ "build": "rm -rf dist-ts dist/src && tsc --noEmit false --noEmitOnError false --outDir dist-ts && mkdir -p dist/src && cp dist-ts/index.js dist-ts/setup-entry.js dist-ts/secret-contract-api.js dist/ && cp dist-ts/src/*.js dist/src/ && rm -f dist/src/*.test.js dist/src/test-setup.js",
29
41
  "prepack": "npm run build",
30
42
  "typecheck": "tsc --noEmit",
31
43
  "lint": "eslint src/",
@@ -44,9 +56,10 @@
44
56
  "!src/**/*.test.ts",
45
57
  "!src/test-setup.ts",
46
58
  "!src/__fixtures__/",
47
- "scripts/",
59
+ "assets/icon.png",
48
60
  "openclaw.plugin.json",
49
61
  "README.md",
62
+ "README_EN.md",
50
63
  "SKILL.md",
51
64
  "LICENSE"
52
65
  ],
@@ -61,7 +74,14 @@
61
74
  "selectionLabel": "MAX (max.ru)",
62
75
  "docsPath": "/channels/max",
63
76
  "docsLabel": "max",
64
- "blurb": "MAX messenger channel (max.ru Bot API)."
77
+ "blurb": "MAX messenger channel (max.ru Bot API).",
78
+ "configuredState": {
79
+ "env": {
80
+ "anyOf": [
81
+ "MAX_BOT_TOKEN"
82
+ ]
83
+ }
84
+ }
65
85
  },
66
86
  "compat": {
67
87
  "pluginApi": ">=2026.9.6",
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Chat admission rules shared by the inbound gate and message-tool action
3
+ * scoping: the DM policy with allowFrom and the pairing store, the group
4
+ * policy with its allowlist, the group sender allowlist.
5
+ */
6
+
7
+ import { isSenderIdAllowed, resolveGroupAllowFromSources } from 'openclaw/plugin-sdk/allow-from';
8
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
9
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
10
+
11
+ import { maxAccountConfigPath, type ResolvedMaxAccount } from './accounts.js';
12
+ import { getMaxRuntime } from './runtime.js';
13
+
14
+ export type MaxAdmission = { admitted: true } | { admitted: false; reason: string };
15
+
16
+ /** Effective group policy: account, then channels.defaults, then allowlist. */
17
+ export function resolveMaxGroupPolicy(account: ResolvedMaxAccount, config: OpenClawConfig): string {
18
+ return account.config.groupPolicy ?? config.channels?.defaults?.groupPolicy ?? 'allowlist';
19
+ }
20
+
21
+ /** Whether a group chat passes the group policy (disabled / allowlist with "*" / open) and is enabled. */
22
+ export function admitMaxGroupChat(
23
+ account: ResolvedMaxAccount,
24
+ config: OpenClawConfig,
25
+ chatId: number | string | undefined,
26
+ ): MaxAdmission {
27
+ const groupPolicy = resolveMaxGroupPolicy(account, config);
28
+ if (groupPolicy === 'disabled') return { admitted: false, reason: 'groupPolicy=disabled' };
29
+ if (groupPolicy === 'allowlist') {
30
+ const groups = account.config.groups ?? {};
31
+ if (!(String(chatId) in groups) && !('*' in groups)) {
32
+ return { admitted: false, reason: 'chat is not in the groups allowlist' };
33
+ }
34
+ }
35
+ // groups.<id>.enabled=false (else the "*" entry's) turns the group off under any policy.
36
+ const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
37
+ if (groupCfg?.enabled === false) return { admitted: false, reason: 'group enabled=false' };
38
+ return { admitted: true };
39
+ }
40
+
41
+ /**
42
+ * Sender allowlist of a group: its own groups.<id>.allowFrom (else the "*"
43
+ * entry's), else the account's groupAllowFrom — the first non-empty one, as
44
+ * core resolves group sender lists. `max:` prefixes are stripped.
45
+ */
46
+ export function resolveMaxGroupSenderAllowFrom(
47
+ account: ResolvedMaxAccount,
48
+ chatId: number | string | undefined,
49
+ ): string[] {
50
+ const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
51
+ const groupAllowFrom = Array.isArray(groupCfg?.allowFrom)
52
+ ? (groupCfg.allowFrom as Array<string | number>)
53
+ : undefined;
54
+ return resolveGroupAllowFromSources({
55
+ groupAllowFrom,
56
+ allowFrom: account.config.groupAllowFrom,
57
+ }).map((entry) => entry.replace(/^max:/i, ''));
58
+ }
59
+
60
+ /**
61
+ * Whether a sender may trigger the bot in a group the group policy admits:
62
+ * anyone when no sender list is set (unlike core's WhatsApp/Telegram, no
63
+ * fallback to the DM allowFrom — MAX never applied it to groups), "*" for
64
+ * anyone, else only the listed user ids. DM pairing approvals don't count.
65
+ */
66
+ export function admitMaxGroupSender(
67
+ account: ResolvedMaxAccount,
68
+ chatId: number | string | undefined,
69
+ senderId: number | string | undefined,
70
+ ): MaxAdmission {
71
+ const entries = resolveMaxGroupSenderAllowFrom(account, chatId);
72
+ const allow = {
73
+ entries,
74
+ hasWildcard: entries.includes('*'),
75
+ hasEntries: entries.length > 0,
76
+ };
77
+ if (isSenderIdAllowed(allow, senderId != null ? String(senderId) : undefined, true)) {
78
+ return { admitted: true };
79
+ }
80
+ return { admitted: false, reason: 'sender is not in the group allowFrom/groupAllowFrom' };
81
+ }
82
+
83
+ /** Group policy, then the group's sender allowlist. */
84
+ export function admitMaxGroupMessage(
85
+ account: ResolvedMaxAccount,
86
+ config: OpenClawConfig,
87
+ chatId: number | string | undefined,
88
+ senderId: number | string | undefined,
89
+ ): MaxAdmission {
90
+ const chat = admitMaxGroupChat(account, config, chatId);
91
+ return chat.admitted ? admitMaxGroupSender(account, chatId, senderId) : chat;
92
+ }
93
+
94
+ /** allowFrom from the config plus the approved pairing store, as strings. */
95
+ export async function readMaxDmAllowFrom(account: ResolvedMaxAccount): Promise<string[]> {
96
+ const configAllowFrom = (account.config.allowFrom ?? []).map(String);
97
+ const storeAllowFrom = await getMaxRuntime()
98
+ .channel.pairing.readAllowFromStore({ channel: 'max', accountId: account.accountId })
99
+ .catch(() => []);
100
+ return [...configAllowFrom, ...storeAllowFrom];
101
+ }
102
+
103
+ /** Whether a DM peer passes the DM policy (disabled / open / allowFrom + pairing store). */
104
+ export async function admitMaxDmUser(
105
+ account: ResolvedMaxAccount,
106
+ userId: number | string | undefined,
107
+ ): Promise<MaxAdmission> {
108
+ const dmPolicy = account.config.dmPolicy ?? 'pairing';
109
+ if (dmPolicy === 'disabled') return { admitted: false, reason: 'dmPolicy=disabled' };
110
+ if (dmPolicy === 'open') return { admitted: true };
111
+ const allowFrom = await readMaxDmAllowFrom(account);
112
+ if (allowFrom.includes('*') || (userId != null && allowFrom.includes(String(userId)))) {
113
+ return { admitted: true };
114
+ }
115
+ return {
116
+ admitted: false,
117
+ reason: `user is not in allowFrom or the pairing store (dmPolicy=${dmPolicy})`,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Start-up warning for a named account that sets no DM or group policy of its
123
+ * own and inherits an open one (channels.max, or channels.defaults for
124
+ * groups): the second bot is then open too. Names the account, the policy
125
+ * and where to set its own; no secrets or user ids. undefined when nothing
126
+ * open is inherited.
127
+ */
128
+ export function describeMaxInheritedOpenAccess(
129
+ cfg: OpenClawConfig | undefined,
130
+ account: ResolvedMaxAccount,
131
+ ): string | undefined {
132
+ if (!cfg || account.accountId === DEFAULT_ACCOUNT_ID) return undefined;
133
+ const channel = cfg.channels?.max as Record<string, unknown> | undefined;
134
+ const accounts = channel?.accounts as Record<string, Record<string, unknown>> | undefined;
135
+ const own = accounts?.[account.accountId] ?? {};
136
+ const inherited: string[] = [];
137
+ const fix: string[] = [];
138
+ if (!Object.hasOwn(own, 'dmPolicy') && account.config.dmPolicy === 'open') {
139
+ inherited.push('dmPolicy="open" from channels.max (anyone can message this bot)');
140
+ fix.push('dmPolicy (e.g. "pairing")');
141
+ }
142
+ if (!Object.hasOwn(own, 'groupPolicy') && resolveMaxGroupPolicy(account, cfg) === 'open') {
143
+ const from = channel?.groupPolicy === 'open' ? 'channels.max' : 'channels.defaults';
144
+ inherited.push(`groupPolicy="open" from ${from} (any group can add and ping this bot)`);
145
+ fix.push('groupPolicy (e.g. "allowlist" with groups)');
146
+ }
147
+ if (inherited.length === 0) return undefined;
148
+ const path = maxAccountConfigPath(account.accountId);
149
+ return (
150
+ `MAX account "${account.accountId}" has no access policy of its own and inherits ` +
151
+ `${inherited.join(' and ')}; set ${fix.map((key) => `${path}.${key}`).join(' / ')} ` +
152
+ 'to give this bot its own access rules.'
153
+ );
154
+ }
package/src/accounts.ts CHANGED
@@ -4,8 +4,19 @@
4
4
 
5
5
  import { lstatSync, readFileSync } from 'node:fs';
6
6
 
7
+ import { mergeAccountConfig } from 'openclaw/plugin-sdk/account-core';
7
8
  import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
8
9
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk/core';
10
+ import { resolveSecretInputString } from 'openclaw/plugin-sdk/secret-input';
11
+ import { canResolveEnvSecretRefInReadOnlyPath } from 'openclaw/plugin-sdk/secret-ref-readonly';
12
+
13
+ import {
14
+ bindMaxNetwork,
15
+ type MaxNetwork,
16
+ MaxNetworkConfigError,
17
+ resolveMaxNetwork,
18
+ } from './network.js';
19
+ import type { MaxSecretField } from './secret-contract.js';
9
20
 
10
21
  export interface MaxAccountConfig {
11
22
  enabled?: boolean;
@@ -14,9 +25,20 @@ export interface MaxAccountConfig {
14
25
  name?: string;
15
26
  dmPolicy?: string;
16
27
  allowFrom?: Array<string | number>;
17
- groups?: Record<string, { requireMention?: boolean; [key: string]: unknown }>;
28
+ groups?: Record<
29
+ string,
30
+ {
31
+ requireMention?: boolean;
32
+ enabled?: boolean;
33
+ systemPrompt?: string;
34
+ skills?: string[];
35
+ [key: string]: unknown;
36
+ }
37
+ >;
18
38
  groupPolicy?: string;
19
39
  groupAllowFrom?: Array<string | number>;
40
+ /** Where core mention patterns apply in groups (core channel mention policy) */
41
+ mentionPatterns?: { mode?: 'allow' | 'deny'; allowIn?: string[]; denyIn?: string[] };
20
42
  /** Update transport; default: "webhook" when webhookUrl is set, otherwise "polling" */
21
43
  transport?: 'polling' | 'webhook';
22
44
  webhookUrl?: string;
@@ -24,10 +46,39 @@ export interface MaxAccountConfig {
24
46
  /** File holding the webhook secret (like tokenFile) */
25
47
  webhookSecretFile?: string;
26
48
  webhookPath?: string;
49
+ /** Webhook queue: durable journal (default) or memory, size and overflow policy */
50
+ webhookQueue?: {
51
+ mode?: 'durable' | 'memory';
52
+ maxPending?: number;
53
+ overflow?: 'reject' | 'drop';
54
+ };
55
+ /** Updates the bot would answer are skipped past this age (default 60; 0 = no limit) */
56
+ maxEventAgeMinutes?: number;
57
+ /** Debug logs carry a text preview of inbound messages (default false) */
58
+ logMessagePreview?: boolean;
27
59
  mediaMaxMb?: number;
28
60
  streamMode?: 'off' | 'partial' | 'block';
29
61
  /** Send mark_seen read receipts on inbound messages (default true) */
30
62
  markSeen?: boolean;
63
+ /** Chats message-tool actions may act in (default "admitted"; see action-scope.ts) */
64
+ actionScope?: 'admitted' | 'current' | 'off';
65
+ /** Default MAX `notify` of sent messages (default true); channels always notify */
66
+ notify?: boolean;
67
+ /** Default: send messages without link previews (MAX `disable_link_preview`) */
68
+ disableLinkPreview?: boolean;
69
+ /** Most media attachments downloaded per inbound message (default 12) */
70
+ mediaMaxCount?: number;
71
+ /** MAX Bot API base URL (default https://platform-api2.max.ru); see network.ts */
72
+ apiBaseUrl?: string;
73
+ /** HTTP(S) proxy for all MAX traffic of the account; may carry credentials */
74
+ httpProxy?: string;
75
+ /** Core streaming settings (streaming.mode, streaming.progress) */
76
+ streaming?: Record<string, unknown>;
77
+ /**
78
+ * Message-tool actions; false turns one off (createActionGate). The schema
79
+ * also accepts legacy policy strings, which leave the action on.
80
+ */
81
+ actions?: Record<string, boolean | undefined>;
31
82
  }
32
83
 
33
84
  export interface ResolvedMaxAccount {
@@ -36,9 +87,47 @@ export interface ResolvedMaxAccount {
36
87
  enabled: boolean;
37
88
  token: string;
38
89
  tokenSource: 'config' | 'env' | 'file' | 'none';
90
+ /**
91
+ * available — a token was found; configured_unavailable — botToken is a
92
+ * SecretRef the gateway did not resolve; missing — no token anywhere.
93
+ */
94
+ tokenStatus?: 'available' | 'configured_unavailable' | 'missing';
95
+ /**
96
+ * Options set to a SecretRef that did not resolve: config path and ref, never
97
+ * a value. The account does not start; it never falls back to a
98
+ * lower-precedence source (token file, MAX_BOT_TOKEN, no proxy, generated
99
+ * webhook secret).
100
+ */
101
+ secretErrors?: Partial<Record<MaxSecretField, string>>;
102
+ /** Set when no token was found: what to configure, or which token file option failed. */
103
+ stateReason?: string;
104
+ /**
105
+ * Account settings; a named account inherits every channel-level value it
106
+ * does not set itself, except MAX_ACCOUNT_OWN_KEYS.
107
+ */
39
108
  config: MaxAccountConfig;
40
109
  }
41
110
 
111
+ /**
112
+ * Channel-level keys a named account never inherits: they belong to one bot
113
+ * (token, token file, display name) or to one webhook subscription (transport,
114
+ * URL, secret, secret file, HTTP path — a shared path would route two bots to
115
+ * one handler). Top-level-only keys (commands, defaultAccount) are not account
116
+ * settings at all.
117
+ */
118
+ export const MAX_ACCOUNT_OWN_KEYS = [
119
+ 'botToken',
120
+ 'tokenFile',
121
+ 'name',
122
+ 'transport',
123
+ 'webhookUrl',
124
+ 'webhookSecret',
125
+ 'webhookSecretFile',
126
+ 'webhookPath',
127
+ 'commands',
128
+ 'defaultAccount',
129
+ ] as const;
130
+
42
131
  /**
43
132
  * Get the MAX channel section from config.
44
133
  */
@@ -63,6 +152,46 @@ function readTokenFile(tokenFile?: string): string {
63
152
  }
64
153
  }
65
154
 
155
+ /** Thrown at account start for an option whose SecretRef did not resolve. */
156
+ export class MaxSecretConfigError extends Error {
157
+ constructor(message: string) {
158
+ super(message);
159
+ this.name = 'MaxSecretConfigError';
160
+ }
161
+ }
162
+
163
+ type SecretOption = { value?: string; error?: string };
164
+
165
+ /**
166
+ * A secret-capable option: a plain string, or a SecretRef the gateway has
167
+ * already replaced by its value in the runtime snapshot. A ref still present
168
+ * is unresolved — except an env ref the read-only SDK check allows (CLI and
169
+ * status paths without a gateway snapshot, as core channels do).
170
+ */
171
+ function readSecretOption(cfg: OpenClawConfig, value: unknown, path: string): SecretOption {
172
+ const resolved = resolveSecretInputString({
173
+ value,
174
+ path,
175
+ defaults: cfg.secrets?.defaults,
176
+ mode: 'inspect',
177
+ });
178
+ if (resolved.status === 'available') return { value: resolved.value };
179
+ if (resolved.status === 'missing') return {};
180
+ const { ref } = resolved;
181
+ if (
182
+ ref.source === 'env' &&
183
+ canResolveEnvSecretRefInReadOnlyPath({ cfg, provider: ref.provider, id: ref.id })
184
+ ) {
185
+ const fromEnv = process.env[ref.id]?.trim();
186
+ if (fromEnv) return { value: fromEnv };
187
+ }
188
+ return {
189
+ error:
190
+ `MAX ${path} is a SecretRef (${ref.source}:${ref.provider}:${ref.id}) that did not ` +
191
+ 'resolve; check the secret and secrets.providers (openclaw secrets audit)',
192
+ };
193
+ }
194
+
66
195
  /**
67
196
  * List all MAX account IDs from config.
68
197
  */
@@ -101,12 +230,71 @@ export function resolveDefaultMaxAccountId(_cfg: OpenClawConfig): string {
101
230
  return DEFAULT_ACCOUNT_ID;
102
231
  }
103
232
 
233
+ /** Config path of an account's own settings, for error messages. */
234
+ export function maxAccountConfigPath(accountId: string): string {
235
+ return accountId === DEFAULT_ACCOUNT_ID ? 'channels.max' : `channels.max.accounts.${accountId}`;
236
+ }
237
+
238
+ /**
239
+ * The account's validated network settings (API base URL, proxy). Throws
240
+ * MaxNetworkConfigError with the config path; never echoes proxy credentials.
241
+ */
242
+ export function resolveMaxAccountNetwork(account: ResolvedMaxAccount): MaxNetwork {
243
+ const proxyError = account.secretErrors?.httpProxy;
244
+ if (proxyError) throw new MaxNetworkConfigError(proxyError);
245
+ return resolveMaxNetwork(account.config, maxAccountConfigPath(account.accountId));
246
+ }
247
+
248
+ function resolveAccountNetwork(
249
+ accountId: string,
250
+ config: MaxAccountConfig,
251
+ proxyError: string | undefined,
252
+ ): MaxNetwork | { error: MaxNetworkConfigError } {
253
+ // An unresolved proxy ref must not mean "connect directly".
254
+ if (proxyError) return { error: new MaxNetworkConfigError(proxyError) };
255
+ try {
256
+ return resolveMaxNetwork(config, maxAccountConfigPath(accountId));
257
+ } catch (err) {
258
+ if (err instanceof MaxNetworkConfigError) return { error: err };
259
+ throw err;
260
+ }
261
+ }
262
+
263
+ /** A token was found, or botToken is a SecretRef that did not resolve. */
264
+ export function isMaxAccountConfigured(account: ResolvedMaxAccount): boolean {
265
+ return Boolean(account.token?.trim()) || account.tokenStatus === 'configured_unavailable';
266
+ }
267
+
104
268
  /**
105
269
  * Resolve a single MAX account from config.
106
270
  */
107
271
  export function resolveMaxAccount(params: {
108
272
  cfg: OpenClawConfig;
109
273
  accountId?: string | null;
274
+ }): ResolvedMaxAccount {
275
+ const account = readMaxAccount(params);
276
+ // Every MaxApi built for this token (any send/action/lifecycle path) uses
277
+ // the account's API base URL and proxy — or fails with their config error.
278
+ bindMaxNetwork(
279
+ account.token,
280
+ resolveAccountNetwork(account.accountId, account.config, account.secretErrors?.httpProxy),
281
+ );
282
+ return account;
283
+ }
284
+
285
+ /** Why an account has no token: the options to set or the unreadable token file (no path of the file itself). */
286
+ function missingTokenReason(accountId: string, tokenFile: string | undefined): string {
287
+ const path = maxAccountConfigPath(accountId);
288
+ if (tokenFile?.trim()) return `${path}.tokenFile is missing, empty or not a regular file`;
289
+ return accountId === DEFAULT_ACCOUNT_ID
290
+ ? `no bot token: set ${path}.botToken, ${path}.tokenFile or MAX_BOT_TOKEN`
291
+ : `no bot token: set ${path}.botToken or ${path}.tokenFile`;
292
+ }
293
+
294
+ /** Account resolution without side effects (resolveMaxAccount also binds its network). */
295
+ export function readMaxAccount(params: {
296
+ cfg: OpenClawConfig;
297
+ accountId?: string | null;
110
298
  }): ResolvedMaxAccount {
111
299
  const { cfg, accountId: rawId } = params;
112
300
  const accountId = rawId ? normalizeAccountId(rawId) : DEFAULT_ACCOUNT_ID;
@@ -116,6 +304,7 @@ export function resolveMaxAccount(params: {
116
304
  let accountConfig: MaxAccountConfig;
117
305
  let token = '';
118
306
  let tokenSource: ResolvedMaxAccount['tokenSource'] = 'none';
307
+ const secretErrors: NonNullable<ResolvedMaxAccount['secretErrors']> = {};
119
308
 
120
309
  if (accountId === DEFAULT_ACCOUNT_ID) {
121
310
  // Default account: top-level config
@@ -134,53 +323,94 @@ export function resolveMaxAccount(params: {
134
323
  webhookSecret: section.webhookSecret as string | undefined,
135
324
  webhookSecretFile: section.webhookSecretFile as string | undefined,
136
325
  webhookPath: section.webhookPath as string | undefined,
326
+ webhookQueue: section.webhookQueue as MaxAccountConfig['webhookQueue'],
327
+ maxEventAgeMinutes: section.maxEventAgeMinutes as number | undefined,
328
+ logMessagePreview: section.logMessagePreview as boolean | undefined,
137
329
  mediaMaxMb: section.mediaMaxMb as number | undefined,
138
330
  streamMode: section.streamMode as MaxAccountConfig['streamMode'],
139
331
  markSeen: section.markSeen as boolean | undefined,
332
+ actionScope: section.actionScope as MaxAccountConfig['actionScope'],
333
+ notify: section.notify as boolean | undefined,
334
+ disableLinkPreview: section.disableLinkPreview as boolean | undefined,
335
+ mediaMaxCount: section.mediaMaxCount as number | undefined,
336
+ apiBaseUrl: section.apiBaseUrl as string | undefined,
337
+ httpProxy: section.httpProxy as string | undefined,
338
+ streaming: section.streaming as MaxAccountConfig['streaming'],
339
+ mentionPatterns: section.mentionPatterns as MaxAccountConfig['mentionPatterns'],
340
+ actions: section.actions as MaxAccountConfig['actions'],
140
341
  };
141
342
 
142
- if (accountConfig.botToken?.trim()) {
143
- token = accountConfig.botToken.trim();
343
+ const botToken = readSecretOption(cfg, section.botToken, 'channels.max.botToken');
344
+ if (botToken.value) {
345
+ token = botToken.value;
144
346
  tokenSource = 'config';
347
+ } else if (botToken.error) {
348
+ tokenSource = 'config';
349
+ secretErrors.botToken = botToken.error;
145
350
  } else if (accountConfig.tokenFile?.trim()) {
146
351
  token = readTokenFile(accountConfig.tokenFile);
147
352
  tokenSource = token ? 'file' : 'none';
148
353
  }
149
- if (!token && process.env.MAX_BOT_TOKEN?.trim()) {
354
+ if (!token && !botToken.error && process.env.MAX_BOT_TOKEN?.trim()) {
150
355
  token = process.env.MAX_BOT_TOKEN.trim();
151
356
  tokenSource = 'env';
152
357
  }
153
358
  } else {
154
- // Named account
359
+ // Named account: the account's own values over the channel-level ones
360
+ // (SDK mergeAccountConfig, as core channels do), minus the per-bot keys.
361
+ // A channel switched off switches off every account.
155
362
  const raw = accounts?.[accountId] ?? {};
156
363
  accountConfig = {
157
- enabled: raw.enabled !== false,
158
- botToken: raw.botToken as string | undefined,
159
- tokenFile: raw.tokenFile as string | undefined,
160
- name: raw.name as string | undefined,
161
- dmPolicy: raw.dmPolicy as string | undefined,
162
- allowFrom: raw.allowFrom as Array<string | number> | undefined,
163
- groups: raw.groups as MaxAccountConfig['groups'],
164
- groupPolicy: raw.groupPolicy as string | undefined,
165
- groupAllowFrom: raw.groupAllowFrom as Array<string | number> | undefined,
166
- transport: raw.transport as MaxAccountConfig['transport'],
167
- webhookUrl: raw.webhookUrl as string | undefined,
168
- webhookSecret: raw.webhookSecret as string | undefined,
169
- webhookSecretFile: raw.webhookSecretFile as string | undefined,
170
- webhookPath: raw.webhookPath as string | undefined,
171
- mediaMaxMb: raw.mediaMaxMb as number | undefined,
172
- streamMode: raw.streamMode as MaxAccountConfig['streamMode'],
173
- markSeen: raw.markSeen as boolean | undefined,
174
- };
364
+ ...mergeAccountConfig<Record<string, unknown>>({
365
+ channelConfig: section,
366
+ accountConfig: raw,
367
+ omitKeys: [...MAX_ACCOUNT_OWN_KEYS],
368
+ }),
369
+ enabled: section.enabled !== false && raw.enabled !== false,
370
+ } as MaxAccountConfig;
175
371
 
176
- if (accountConfig.botToken?.trim()) {
177
- token = accountConfig.botToken.trim();
372
+ const botToken = readSecretOption(
373
+ cfg,
374
+ raw.botToken,
375
+ `${maxAccountConfigPath(accountId)}.botToken`,
376
+ );
377
+ if (botToken.value) {
378
+ token = botToken.value;
379
+ tokenSource = 'config';
380
+ } else if (botToken.error) {
178
381
  tokenSource = 'config';
382
+ secretErrors.botToken = botToken.error;
179
383
  } else if (accountConfig.tokenFile?.trim()) {
180
384
  token = readTokenFile(accountConfig.tokenFile);
181
385
  tokenSource = token ? 'file' : 'none';
182
386
  }
183
387
  }
388
+ // From here on a SecretRef option holds its value, or is absent.
389
+ const setResolved = (key: MaxSecretField, value: string | undefined) => {
390
+ if (!(key in accountConfig)) return;
391
+ if (value === undefined) delete accountConfig[key];
392
+ else accountConfig[key] = value;
393
+ };
394
+ setResolved('botToken', tokenSource === 'config' && token ? token : undefined);
395
+
396
+ // The webhook secret is per-bot; a named account's proxy may be inherited.
397
+ const webhookSecret = readSecretOption(
398
+ cfg,
399
+ accountConfig.webhookSecret,
400
+ `${maxAccountConfigPath(accountId)}.webhookSecret`,
401
+ );
402
+ setResolved('webhookSecret', webhookSecret.value);
403
+ if (webhookSecret.error) secretErrors.webhookSecret = webhookSecret.error;
404
+ const ownProxy =
405
+ accountId === DEFAULT_ACCOUNT_ID || Object.hasOwn(accounts?.[accountId] ?? {}, 'httpProxy');
406
+ const httpProxy = readSecretOption(
407
+ cfg,
408
+ accountConfig.httpProxy,
409
+ `${ownProxy ? maxAccountConfigPath(accountId) : 'channels.max'}.httpProxy`,
410
+ );
411
+ // "" still turns an inherited proxy off.
412
+ setResolved('httpProxy', httpProxy.value ?? (accountConfig.httpProxy === '' ? '' : undefined));
413
+ if (httpProxy.error) secretErrors.httpProxy = httpProxy.error;
184
414
 
185
415
  return {
186
416
  accountId,
@@ -188,6 +418,11 @@ export function resolveMaxAccount(params: {
188
418
  enabled: accountConfig.enabled ?? true,
189
419
  token,
190
420
  tokenSource,
421
+ tokenStatus: token ? 'available' : secretErrors.botToken ? 'configured_unavailable' : 'missing',
422
+ ...(Object.keys(secretErrors).length > 0 ? { secretErrors } : {}),
423
+ ...(token || secretErrors.botToken
424
+ ? {}
425
+ : { stateReason: missingTokenReason(accountId, accountConfig.tokenFile) }),
191
426
  config: accountConfig,
192
427
  };
193
428
  }