@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.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/package.json
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspalagin/openclaw-max",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MAX messenger channel
|
|
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
|
-
"
|
|
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<
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
}
|