@aspalagin/openclaw-max 0.7.3 → 0.8.1
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-policy.js +19 -6
- 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 +983 -8
- package/package.json +17 -4
- 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-policy.ts +27 -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/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: MAX
|
|
3
|
-
description: Канал-плагин для подключения OpenClaw к мессенджеру MAX (max.ru).
|
|
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
|
-
-
|
|
13
|
-
- **Медиа** — фото, видео,
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
-
"
|
|
55
|
-
"dmPolicy": "pairing",
|
|
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
|
|
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 "
|
|
65
|
+
openclaw message send --channel max --target "user:12345678" --message "Привет из OpenClaw!"
|
|
76
66
|
```
|
|
77
67
|
|
|
78
|
-
Цели:
|
|
68
|
+
Цели: `user:<id>` — личный диалог с пользователем; числовой `chat_id` — группа или канал (например, `-70000000000001`). `@username` и ссылки max.ru не поддерживаются: MAX Bot API их не разрешает, плагин сразу отвечает понятной ошибкой.
|
|
79
69
|
|
|
80
|
-
### Кнопки
|
|
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
|
-
В диалогах
|
|
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.
|
|
104
|
-
- Node.js
|
|
111
|
+
- OpenClaw ≥ 2026.9.6
|
|
112
|
+
- Node.js ≥ 22
|
|
105
113
|
|
|
106
114
|
## Поддержка
|
|
107
115
|
|
package/assets/icon.png
ADDED
|
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
|
+
}
|
package/dist/src/accounts.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
}
|