@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/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: MAX
3
- description: Канал-плагин для подключения OpenClaw к мессенджеру MAX (max.ru). Поддерживает отправку и получение текстовых сообщений, медиа, inline-кнопки (callback/link/message/clipboard/open_app), pin/unpin, форматирование в MAX-диалекте markdown. Работает в режимах long polling и webhook. Используй когда нужно отправить сообщение через мессенджер MAX или обработать входящие сообщения из MAX.
3
+ description: Канал-плагин для подключения OpenClaw к мессенджеру MAX (max.ru). Отправка и получение текста, медиа, голосовых, пересланных сообщений, inline-кнопки (callback/link/message/clipboard/open_app), pin/unpin, markdown в диалекте MAX. Работает через webhook или long polling. Используй, когда нужно отправить сообщение через MAX или обработать входящие сообщения из MAX.
4
4
  ---
5
5
 
6
6
  # MAX — плагин для OpenClaw
@@ -9,39 +9,32 @@ description: Канал-плагин для подключения OpenClaw к
9
9
 
10
10
  ## Что делает
11
11
 
12
- - **Текстовые сообщения** — отправка и получение; исходящий markdown конвертируется в MAX-диалект (`++подчёркивание++`)
13
- - **Медиа** — фото, видео, аудио (включая m4a/heic с iPhone), файлы, стикеры, контакты, геолокация
14
- - **Inline-кнопки** — callback, link, message (suggested replies), clipboard, open_app, request_contact, request_geo_location
15
- - **Pin/unpin** — закрепление сообщений в чатах
16
- - **Long polling** — с персистентным marker (рестарты без потери/дублей событий)
17
- - **Webhook** — HTTPS c обязательным secret (X-Max-Bot-Api-Secret), мгновенный ACK, последовательная обработка
18
- - **Команды бота** — регистрация через PATCH /me/commands из `channels.max.commands`
19
- - **Мультиаккаунт**, DM-security (pairing/allowlist/open), пер-групповые политики
12
+ - **Текст** — отправка и получение; исходящий markdown переводится в диалект MAX (`++подчёркивание++`). Если MAX отверг разметку, сообщение уходит обычным текстом.
13
+ - **Медиа** — фото, видео, аудио, файлы, стикеры, контакты, геолокация; несколько вложений — альбомами до 12.
14
+ - **Голосовые** — входящие распознаёт ядро (`tools.media.audio`) или берётся транскрипт MAX; голосовой ответ уходит аудиовложением MAX.
15
+ - **Пересылки и цитаты** — агент видит пересланные сообщения и цитату сообщения, на которое ответили.
16
+ - **Inline-кнопки** — callback, link, message (подсказки ответа), clipboard, open_app, request_contact, request_geo_location; меню `/think`, `/fast` и других команд кнопками.
17
+ - **Pin/unpin** — закрепление в группах и каналах.
18
+ - **Webhook** (рекомендуется MAX для боевой работы) и **long polling**; события webhook переживают рестарт gateway.
19
+ - **Команды бота** — регистрация через `PATCH /me/commands` из `channels.max.commands`.
20
+ - **Несколько аккаунтов**, политики доступа для личных сообщений (pairing/allowlist/open/disabled) и групп.
20
21
 
21
- TLS-сертификат Минцифры для platform-api2.max.ru встроен в плагин — дополнительная настройка не нужна.
22
+ TLS-сертификат Минцифры для `platform-api2.max.ru` встроен в плагин и используется только для его соединений — отдельная настройка не нужна.
22
23
 
23
24
  ## Установка
24
25
 
25
- Через ClawHub (рекомендуется):
26
+ Через ClawHub:
26
27
 
27
28
  ```bash
28
29
  openclaw plugins install clawhub:@aspalagin/openclaw-max
29
30
  ```
30
31
 
31
- Через npm:
32
+ Через npm (имя с областью; пакет `openclaw-max` без области — другой проект):
32
33
 
33
34
  ```bash
34
35
  openclaw plugins install npm:@aspalagin/openclaw-max
35
36
  ```
36
37
 
37
- Вручную из исходников (для разработки):
38
-
39
- ```bash
40
- cd ~/.openclaw/extensions
41
- git clone https://github.com/aspalagin/openclaw-max openclaw-max
42
- cd openclaw-max && npm install && npm run build
43
- ```
44
-
45
38
  ## Настройка
46
39
 
47
40
  В `~/.openclaw/openclaw.json`:
@@ -51,8 +44,8 @@ cd openclaw-max && npm install && npm run build
51
44
  "channels": {
52
45
  "max": {
53
46
  "enabled": true,
54
- "botToken": "ТОКЕН_БОТА", // или env MAX_BOT_TOKEN
55
- "dmPolicy": "pairing", // pairing | allowlist | open | disabled
47
+ "tokenFile": "/home/you/.openclaw/secrets/max-bot-token", // абсолютный путь, обычный файл; или botToken (строка или SecretRef), или env MAX_BOT_TOKEN
48
+ "dmPolicy": "pairing", // pairing | allowlist | open | disabled
56
49
  "allowFrom": ["12345678"],
57
50
  "commands": [
58
51
  { "name": "status", "description": "Статус ассистента" }
@@ -62,22 +55,19 @@ cd openclaw-max && npm install && npm run build
62
55
  }
63
56
  ```
64
57
 
65
- Webhook-режим: добавить `"transport": "webhook"`, `"webhookUrl": "https://ваш-домен/max/webhook"`
66
- (только HTTPS:443, доверенный сертификат) и `"webhookSecretFile"` (без него secret
67
- генерируется и хранится в state-файле). Роут поднимается на HTTP-сервере gateway,
68
- подписку создаёт плагин; порядок включения и откат — README, раздел «Webhook».
58
+ Webhook: `"webhookUrl": "https://bot.example.com/max/webhook"` (только HTTPS на порту 443 с доверенным сертификатом) и `"webhookSecretFile"`; без секрета плагин сгенерирует его сам и сохранит в каталоге состояния. Роут поднимается на HTTP-сервере gateway, подписку создаёт плагин. Подробности и порядок включения — README, раздел «Транспорты: webhook и long polling».
69
59
 
70
- ## Примеры использования
60
+ ## Примеры
71
61
 
72
62
  ### Отправка сообщения
73
63
 
74
64
  ```bash
75
- openclaw message send --channel max --target "144660345" --message "Привет из OpenClaw!"
65
+ openclaw message send --channel max --target "user:12345678" --message "Привет из OpenClaw!"
76
66
  ```
77
67
 
78
- Цели: числовой chat_id, `user:<id>` (адресация в личку; голое число, для которого MAX ответил `dialog.not.found`, повторяется как user_id), `@username` и ссылки max.ru не поддерживаются: MAX Bot API не резолвит их (`chat.not.found`), плагин сразу отвечает понятной ошибкой — для чатов и каналов используйте числовой chat_id.
68
+ Цели: `user:<id>` — личный диалог с пользователем; числовой `chat_id` — группа или канал (например, `-70000000000001`). `@username` и ссылки max.ru не поддерживаются: MAX Bot API их не разрешает, плагин сразу отвечает понятной ошибкой.
79
69
 
80
- ### Кнопки (из message-tool агента)
70
+ ### Кнопки
81
71
 
82
72
  ```
83
73
  message(action="send", target="CHAT_ID", message="Выберите:",
@@ -85,6 +75,28 @@ message(action="send", target="CHAT_ID", message="Выберите:",
85
75
  {"text":"Подробнее","type":"message"}]])
86
76
  ```
87
77
 
78
+ ### Файлы, голосовые, тихая отправка
79
+
80
+ ```
81
+ message(action="sendAttachment", target="CHAT_ID", path="report.pdf", caption="Отчёт")
82
+ message(action="sendAttachment", target="CHAT_ID", buffer="data:text/plain;base64,SGVsbG8=", filename="hello.txt")
83
+ message(action="send", target="CHAT_ID", path="reply.ogg", asVoice=true)
84
+ message(action="send", target="CHAT_ID", message="Без уведомления", silent=true)
85
+ ```
86
+
87
+ - `asVoice=true` с аудиофайлом (`mp3`, `m4a`, `wav`, `ogg`, `opus`) отправляет аудиосообщение MAX, а не файл. Если MAX отказал в аудио, те же байты уходят файлом.
88
+ - `buffer` — содержимое в base64 или data URL, с `filename` и `contentType`; лимит размера — `mediaMaxMb` (по умолчанию 20 МБ). Если заданы и путь, и `buffer`, отправляется файл по пути.
89
+ - `silent=true` — без push-уведомления. В каналы MAX сообщения всегда уходят с уведомлением.
90
+ - Несколько вложений: `attachments=[{"path": …}, {"buffer": …, "filename": …}]` — фото и видео альбомами до 12, аудио и файлы по одному; в ответе `messageIds`, неудачные — в `mediaErrors`.
91
+
92
+ ### Ограничение локальных файлов
93
+
94
+ Отправить можно только файлы из каталогов, которые OpenClaw разрешает агенту: его рабочий каталог и media-каталоги gateway. Путь вне них отклоняется ошибкой `Local media path is not under an allowed directory`; выход через `..` и символические ссылки тоже закрыт. Нужен файл извне — сначала скопировать его в рабочий каталог. Если у агента чтение с хоста, путь может быть любым, но ядро пропускает только проверенные по содержимому типы (изображения, аудио, видео, PDF, документы Office, архивы, текст `.txt`/`.md`/`.csv`/`.json`/`.yaml`); отказ — `Host-local media sends only allow …`.
95
+
96
+ ### В каких чатах работают действия
97
+
98
+ `send`, `sendAttachment`, `sticker`, `edit`, `delete`, `pin`, `unpin` выполняются в текущем чате, по запросу владельца или в чатах, которые допускает политика доступа канала (`channels.max.actionScope`, по умолчанию `admitted`; `current` — только текущий чат; `off` — без проверки). Отказ — ошибка инструмента до любого изменения в MAX: сообщить о нём, не пытаться повторить в другом чате.
99
+
88
100
  ### Pin
89
101
 
90
102
  ```
@@ -92,16 +104,12 @@ message(action="pin", target="CHAT_ID", messageId="MID")
92
104
  message(action="unpin", target="CHAT_ID")
93
105
  ```
94
106
 
95
- В диалогах (личка, `user:<id>`) MAX не поддерживает закреп: плагин не вызывает API и возвращает `pinned: false` с причиной.
96
-
97
- ### Несколько вложений
98
-
99
- `message(action="send", target=…, message="Подпись", attachments=[{"path": …}, {"path": …}])` отправляет все вложения: фото/видео альбомами до 12, файлы и аудио по одному; в ответе `messageIds`.
107
+ В личных диалогах MAX не поддерживает закрепление: плагин не вызывает API и возвращает `pinned: false` с причиной.
100
108
 
101
109
  ## Требования
102
110
 
103
- - OpenClaw 2026.x (plugin SDK)
104
- - Node.js >= 20
111
+ - OpenClaw ≥ 2026.9.6
112
+ - Node.js ≥ 22
105
113
 
106
114
  ## Поддержка
107
115
 
Binary file
@@ -0,0 +1,6 @@
1
+ /**
2
+ * MAX channel secret contract — the artifact the gateway loads before the
3
+ * plugin runtime (`dist/secret-contract-api.js`) to resolve SecretRef options
4
+ * and to list them for `openclaw secrets`. See src/secret-contract.ts.
5
+ */
6
+ export { collectRuntimeConfigAssignments, maxChannelSecrets as channelSecrets, secretTargetRegistryEntries, } from './src/secret-contract.js';
@@ -0,0 +1,123 @@
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
+ import { isSenderIdAllowed, resolveGroupAllowFromSources } from 'openclaw/plugin-sdk/allow-from';
7
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
8
+ import { maxAccountConfigPath } from './accounts.js';
9
+ import { getMaxRuntime } from './runtime.js';
10
+ /** Effective group policy: account, then channels.defaults, then allowlist. */
11
+ export function resolveMaxGroupPolicy(account, config) {
12
+ return account.config.groupPolicy ?? config.channels?.defaults?.groupPolicy ?? 'allowlist';
13
+ }
14
+ /** Whether a group chat passes the group policy (disabled / allowlist with "*" / open) and is enabled. */
15
+ export function admitMaxGroupChat(account, config, chatId) {
16
+ const groupPolicy = resolveMaxGroupPolicy(account, config);
17
+ if (groupPolicy === 'disabled')
18
+ return { admitted: false, reason: 'groupPolicy=disabled' };
19
+ if (groupPolicy === 'allowlist') {
20
+ const groups = account.config.groups ?? {};
21
+ if (!(String(chatId) in groups) && !('*' in groups)) {
22
+ return { admitted: false, reason: 'chat is not in the groups allowlist' };
23
+ }
24
+ }
25
+ // groups.<id>.enabled=false (else the "*" entry's) turns the group off under any policy.
26
+ const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
27
+ if (groupCfg?.enabled === false)
28
+ return { admitted: false, reason: 'group enabled=false' };
29
+ return { admitted: true };
30
+ }
31
+ /**
32
+ * Sender allowlist of a group: its own groups.<id>.allowFrom (else the "*"
33
+ * entry's), else the account's groupAllowFrom — the first non-empty one, as
34
+ * core resolves group sender lists. `max:` prefixes are stripped.
35
+ */
36
+ export function resolveMaxGroupSenderAllowFrom(account, chatId) {
37
+ const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
38
+ const groupAllowFrom = Array.isArray(groupCfg?.allowFrom)
39
+ ? groupCfg.allowFrom
40
+ : undefined;
41
+ return resolveGroupAllowFromSources({
42
+ groupAllowFrom,
43
+ allowFrom: account.config.groupAllowFrom,
44
+ }).map((entry) => entry.replace(/^max:/i, ''));
45
+ }
46
+ /**
47
+ * Whether a sender may trigger the bot in a group the group policy admits:
48
+ * anyone when no sender list is set (unlike core's WhatsApp/Telegram, no
49
+ * fallback to the DM allowFrom — MAX never applied it to groups), "*" for
50
+ * anyone, else only the listed user ids. DM pairing approvals don't count.
51
+ */
52
+ export function admitMaxGroupSender(account, chatId, senderId) {
53
+ const entries = resolveMaxGroupSenderAllowFrom(account, chatId);
54
+ const allow = {
55
+ entries,
56
+ hasWildcard: entries.includes('*'),
57
+ hasEntries: entries.length > 0,
58
+ };
59
+ if (isSenderIdAllowed(allow, senderId != null ? String(senderId) : undefined, true)) {
60
+ return { admitted: true };
61
+ }
62
+ return { admitted: false, reason: 'sender is not in the group allowFrom/groupAllowFrom' };
63
+ }
64
+ /** Group policy, then the group's sender allowlist. */
65
+ export function admitMaxGroupMessage(account, config, chatId, senderId) {
66
+ const chat = admitMaxGroupChat(account, config, chatId);
67
+ return chat.admitted ? admitMaxGroupSender(account, chatId, senderId) : chat;
68
+ }
69
+ /** allowFrom from the config plus the approved pairing store, as strings. */
70
+ export async function readMaxDmAllowFrom(account) {
71
+ const configAllowFrom = (account.config.allowFrom ?? []).map(String);
72
+ const storeAllowFrom = await getMaxRuntime()
73
+ .channel.pairing.readAllowFromStore({ channel: 'max', accountId: account.accountId })
74
+ .catch(() => []);
75
+ return [...configAllowFrom, ...storeAllowFrom];
76
+ }
77
+ /** Whether a DM peer passes the DM policy (disabled / open / allowFrom + pairing store). */
78
+ export async function admitMaxDmUser(account, userId) {
79
+ const dmPolicy = account.config.dmPolicy ?? 'pairing';
80
+ if (dmPolicy === 'disabled')
81
+ return { admitted: false, reason: 'dmPolicy=disabled' };
82
+ if (dmPolicy === 'open')
83
+ return { admitted: true };
84
+ const allowFrom = await readMaxDmAllowFrom(account);
85
+ if (allowFrom.includes('*') || (userId != null && allowFrom.includes(String(userId)))) {
86
+ return { admitted: true };
87
+ }
88
+ return {
89
+ admitted: false,
90
+ reason: `user is not in allowFrom or the pairing store (dmPolicy=${dmPolicy})`,
91
+ };
92
+ }
93
+ /**
94
+ * Start-up warning for a named account that sets no DM or group policy of its
95
+ * own and inherits an open one (channels.max, or channels.defaults for
96
+ * groups): the second bot is then open too. Names the account, the policy
97
+ * and where to set its own; no secrets or user ids. undefined when nothing
98
+ * open is inherited.
99
+ */
100
+ export function describeMaxInheritedOpenAccess(cfg, account) {
101
+ if (!cfg || account.accountId === DEFAULT_ACCOUNT_ID)
102
+ return undefined;
103
+ const channel = cfg.channels?.max;
104
+ const accounts = channel?.accounts;
105
+ const own = accounts?.[account.accountId] ?? {};
106
+ const inherited = [];
107
+ const fix = [];
108
+ if (!Object.hasOwn(own, 'dmPolicy') && account.config.dmPolicy === 'open') {
109
+ inherited.push('dmPolicy="open" from channels.max (anyone can message this bot)');
110
+ fix.push('dmPolicy (e.g. "pairing")');
111
+ }
112
+ if (!Object.hasOwn(own, 'groupPolicy') && resolveMaxGroupPolicy(account, cfg) === 'open') {
113
+ const from = channel?.groupPolicy === 'open' ? 'channels.max' : 'channels.defaults';
114
+ inherited.push(`groupPolicy="open" from ${from} (any group can add and ping this bot)`);
115
+ fix.push('groupPolicy (e.g. "allowlist" with groups)');
116
+ }
117
+ if (inherited.length === 0)
118
+ return undefined;
119
+ const path = maxAccountConfigPath(account.accountId);
120
+ return (`MAX account "${account.accountId}" has no access policy of its own and inherits ` +
121
+ `${inherited.join(' and ')}; set ${fix.map((key) => `${path}.${key}`).join(' / ')} ` +
122
+ 'to give this bot its own access rules.');
123
+ }
@@ -2,7 +2,30 @@
2
2
  * MAX account resolution — reads config and produces a resolved account object.
3
3
  */
4
4
  import { lstatSync, readFileSync } from 'node:fs';
5
+ import { mergeAccountConfig } from 'openclaw/plugin-sdk/account-core';
5
6
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk/core';
7
+ import { resolveSecretInputString } from 'openclaw/plugin-sdk/secret-input';
8
+ import { canResolveEnvSecretRefInReadOnlyPath } from 'openclaw/plugin-sdk/secret-ref-readonly';
9
+ import { bindMaxNetwork, MaxNetworkConfigError, resolveMaxNetwork, } from './network.js';
10
+ /**
11
+ * Channel-level keys a named account never inherits: they belong to one bot
12
+ * (token, token file, display name) or to one webhook subscription (transport,
13
+ * URL, secret, secret file, HTTP path — a shared path would route two bots to
14
+ * one handler). Top-level-only keys (commands, defaultAccount) are not account
15
+ * settings at all.
16
+ */
17
+ export const MAX_ACCOUNT_OWN_KEYS = [
18
+ 'botToken',
19
+ 'tokenFile',
20
+ 'name',
21
+ 'transport',
22
+ 'webhookUrl',
23
+ 'webhookSecret',
24
+ 'webhookSecretFile',
25
+ 'webhookPath',
26
+ 'commands',
27
+ 'defaultAccount',
28
+ ];
6
29
  /**
7
30
  * Get the MAX channel section from config.
8
31
  */
@@ -27,6 +50,42 @@ function readTokenFile(tokenFile) {
27
50
  return '';
28
51
  }
29
52
  }
53
+ /** Thrown at account start for an option whose SecretRef did not resolve. */
54
+ export class MaxSecretConfigError extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'MaxSecretConfigError';
58
+ }
59
+ }
60
+ /**
61
+ * A secret-capable option: a plain string, or a SecretRef the gateway has
62
+ * already replaced by its value in the runtime snapshot. A ref still present
63
+ * is unresolved — except an env ref the read-only SDK check allows (CLI and
64
+ * status paths without a gateway snapshot, as core channels do).
65
+ */
66
+ function readSecretOption(cfg, value, path) {
67
+ const resolved = resolveSecretInputString({
68
+ value,
69
+ path,
70
+ defaults: cfg.secrets?.defaults,
71
+ mode: 'inspect',
72
+ });
73
+ if (resolved.status === 'available')
74
+ return { value: resolved.value };
75
+ if (resolved.status === 'missing')
76
+ return {};
77
+ const { ref } = resolved;
78
+ if (ref.source === 'env' &&
79
+ canResolveEnvSecretRefInReadOnlyPath({ cfg, provider: ref.provider, id: ref.id })) {
80
+ const fromEnv = process.env[ref.id]?.trim();
81
+ if (fromEnv)
82
+ return { value: fromEnv };
83
+ }
84
+ return {
85
+ error: `MAX ${path} is a SecretRef (${ref.source}:${ref.provider}:${ref.id}) that did not ` +
86
+ 'resolve; check the secret and secrets.providers (openclaw secrets audit)',
87
+ };
88
+ }
30
89
  /**
31
90
  * List all MAX account IDs from config.
32
91
  */
@@ -61,10 +120,58 @@ export function listMaxAccountIds(cfg) {
61
120
  export function resolveDefaultMaxAccountId(_cfg) {
62
121
  return DEFAULT_ACCOUNT_ID;
63
122
  }
123
+ /** Config path of an account's own settings, for error messages. */
124
+ export function maxAccountConfigPath(accountId) {
125
+ return accountId === DEFAULT_ACCOUNT_ID ? 'channels.max' : `channels.max.accounts.${accountId}`;
126
+ }
127
+ /**
128
+ * The account's validated network settings (API base URL, proxy). Throws
129
+ * MaxNetworkConfigError with the config path; never echoes proxy credentials.
130
+ */
131
+ export function resolveMaxAccountNetwork(account) {
132
+ const proxyError = account.secretErrors?.httpProxy;
133
+ if (proxyError)
134
+ throw new MaxNetworkConfigError(proxyError);
135
+ return resolveMaxNetwork(account.config, maxAccountConfigPath(account.accountId));
136
+ }
137
+ function resolveAccountNetwork(accountId, config, proxyError) {
138
+ // An unresolved proxy ref must not mean "connect directly".
139
+ if (proxyError)
140
+ return { error: new MaxNetworkConfigError(proxyError) };
141
+ try {
142
+ return resolveMaxNetwork(config, maxAccountConfigPath(accountId));
143
+ }
144
+ catch (err) {
145
+ if (err instanceof MaxNetworkConfigError)
146
+ return { error: err };
147
+ throw err;
148
+ }
149
+ }
150
+ /** A token was found, or botToken is a SecretRef that did not resolve. */
151
+ export function isMaxAccountConfigured(account) {
152
+ return Boolean(account.token?.trim()) || account.tokenStatus === 'configured_unavailable';
153
+ }
64
154
  /**
65
155
  * Resolve a single MAX account from config.
66
156
  */
67
157
  export function resolveMaxAccount(params) {
158
+ const account = readMaxAccount(params);
159
+ // Every MaxApi built for this token (any send/action/lifecycle path) uses
160
+ // the account's API base URL and proxy — or fails with their config error.
161
+ bindMaxNetwork(account.token, resolveAccountNetwork(account.accountId, account.config, account.secretErrors?.httpProxy));
162
+ return account;
163
+ }
164
+ /** Why an account has no token: the options to set or the unreadable token file (no path of the file itself). */
165
+ function missingTokenReason(accountId, tokenFile) {
166
+ const path = maxAccountConfigPath(accountId);
167
+ if (tokenFile?.trim())
168
+ return `${path}.tokenFile is missing, empty or not a regular file`;
169
+ return accountId === DEFAULT_ACCOUNT_ID
170
+ ? `no bot token: set ${path}.botToken, ${path}.tokenFile or MAX_BOT_TOKEN`
171
+ : `no bot token: set ${path}.botToken or ${path}.tokenFile`;
172
+ }
173
+ /** Account resolution without side effects (resolveMaxAccount also binds its network). */
174
+ export function readMaxAccount(params) {
68
175
  const { cfg, accountId: rawId } = params;
69
176
  const accountId = rawId ? normalizeAccountId(rawId) : DEFAULT_ACCOUNT_ID;
70
177
  const section = getMaxSection(cfg) ?? {};
@@ -72,6 +179,7 @@ export function resolveMaxAccount(params) {
72
179
  let accountConfig;
73
180
  let token = '';
74
181
  let tokenSource = 'none';
182
+ const secretErrors = {};
75
183
  if (accountId === DEFAULT_ACCOUNT_ID) {
76
184
  // Default account: top-level config
77
185
  accountConfig = {
@@ -89,60 +197,99 @@ export function resolveMaxAccount(params) {
89
197
  webhookSecret: section.webhookSecret,
90
198
  webhookSecretFile: section.webhookSecretFile,
91
199
  webhookPath: section.webhookPath,
200
+ webhookQueue: section.webhookQueue,
201
+ maxEventAgeMinutes: section.maxEventAgeMinutes,
202
+ logMessagePreview: section.logMessagePreview,
92
203
  mediaMaxMb: section.mediaMaxMb,
93
204
  streamMode: section.streamMode,
94
205
  markSeen: section.markSeen,
206
+ actionScope: section.actionScope,
207
+ notify: section.notify,
208
+ disableLinkPreview: section.disableLinkPreview,
209
+ mediaMaxCount: section.mediaMaxCount,
210
+ apiBaseUrl: section.apiBaseUrl,
211
+ httpProxy: section.httpProxy,
212
+ streaming: section.streaming,
213
+ mentionPatterns: section.mentionPatterns,
214
+ actions: section.actions,
95
215
  };
96
- if (accountConfig.botToken?.trim()) {
97
- token = accountConfig.botToken.trim();
216
+ const botToken = readSecretOption(cfg, section.botToken, 'channels.max.botToken');
217
+ if (botToken.value) {
218
+ token = botToken.value;
219
+ tokenSource = 'config';
220
+ }
221
+ else if (botToken.error) {
98
222
  tokenSource = 'config';
223
+ secretErrors.botToken = botToken.error;
99
224
  }
100
225
  else if (accountConfig.tokenFile?.trim()) {
101
226
  token = readTokenFile(accountConfig.tokenFile);
102
227
  tokenSource = token ? 'file' : 'none';
103
228
  }
104
- if (!token && process.env.MAX_BOT_TOKEN?.trim()) {
229
+ if (!token && !botToken.error && process.env.MAX_BOT_TOKEN?.trim()) {
105
230
  token = process.env.MAX_BOT_TOKEN.trim();
106
231
  tokenSource = 'env';
107
232
  }
108
233
  }
109
234
  else {
110
- // Named account
235
+ // Named account: the account's own values over the channel-level ones
236
+ // (SDK mergeAccountConfig, as core channels do), minus the per-bot keys.
237
+ // A channel switched off switches off every account.
111
238
  const raw = accounts?.[accountId] ?? {};
112
239
  accountConfig = {
113
- enabled: raw.enabled !== false,
114
- botToken: raw.botToken,
115
- tokenFile: raw.tokenFile,
116
- name: raw.name,
117
- dmPolicy: raw.dmPolicy,
118
- allowFrom: raw.allowFrom,
119
- groups: raw.groups,
120
- groupPolicy: raw.groupPolicy,
121
- groupAllowFrom: raw.groupAllowFrom,
122
- transport: raw.transport,
123
- webhookUrl: raw.webhookUrl,
124
- webhookSecret: raw.webhookSecret,
125
- webhookSecretFile: raw.webhookSecretFile,
126
- webhookPath: raw.webhookPath,
127
- mediaMaxMb: raw.mediaMaxMb,
128
- streamMode: raw.streamMode,
129
- markSeen: raw.markSeen,
240
+ ...mergeAccountConfig({
241
+ channelConfig: section,
242
+ accountConfig: raw,
243
+ omitKeys: [...MAX_ACCOUNT_OWN_KEYS],
244
+ }),
245
+ enabled: section.enabled !== false && raw.enabled !== false,
130
246
  };
131
- if (accountConfig.botToken?.trim()) {
132
- token = accountConfig.botToken.trim();
247
+ const botToken = readSecretOption(cfg, raw.botToken, `${maxAccountConfigPath(accountId)}.botToken`);
248
+ if (botToken.value) {
249
+ token = botToken.value;
133
250
  tokenSource = 'config';
134
251
  }
252
+ else if (botToken.error) {
253
+ tokenSource = 'config';
254
+ secretErrors.botToken = botToken.error;
255
+ }
135
256
  else if (accountConfig.tokenFile?.trim()) {
136
257
  token = readTokenFile(accountConfig.tokenFile);
137
258
  tokenSource = token ? 'file' : 'none';
138
259
  }
139
260
  }
261
+ // From here on a SecretRef option holds its value, or is absent.
262
+ const setResolved = (key, value) => {
263
+ if (!(key in accountConfig))
264
+ return;
265
+ if (value === undefined)
266
+ delete accountConfig[key];
267
+ else
268
+ accountConfig[key] = value;
269
+ };
270
+ setResolved('botToken', tokenSource === 'config' && token ? token : undefined);
271
+ // The webhook secret is per-bot; a named account's proxy may be inherited.
272
+ const webhookSecret = readSecretOption(cfg, accountConfig.webhookSecret, `${maxAccountConfigPath(accountId)}.webhookSecret`);
273
+ setResolved('webhookSecret', webhookSecret.value);
274
+ if (webhookSecret.error)
275
+ secretErrors.webhookSecret = webhookSecret.error;
276
+ const ownProxy = accountId === DEFAULT_ACCOUNT_ID || Object.hasOwn(accounts?.[accountId] ?? {}, 'httpProxy');
277
+ const httpProxy = readSecretOption(cfg, accountConfig.httpProxy, `${ownProxy ? maxAccountConfigPath(accountId) : 'channels.max'}.httpProxy`);
278
+ // "" still turns an inherited proxy off.
279
+ setResolved('httpProxy', httpProxy.value ?? (accountConfig.httpProxy === '' ? '' : undefined));
280
+ if (httpProxy.error)
281
+ secretErrors.httpProxy = httpProxy.error;
140
282
  return {
141
283
  accountId,
142
284
  name: accountConfig.name,
143
285
  enabled: accountConfig.enabled ?? true,
144
286
  token,
145
287
  tokenSource,
288
+ tokenStatus: token ? 'available' : secretErrors.botToken ? 'configured_unavailable' : 'missing',
289
+ ...(Object.keys(secretErrors).length > 0 ? { secretErrors } : {}),
290
+ ...(token || secretErrors.botToken
291
+ ? {}
292
+ : { stateReason: missingTokenReason(accountId, accountConfig.tokenFile) }),
146
293
  config: accountConfig,
147
294
  };
148
295
  }