@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/src/channel-lifecycle.ts
CHANGED
|
@@ -7,9 +7,16 @@ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
|
|
|
7
7
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
8
8
|
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import { describeMaxInheritedOpenAccess } from './access-policy.js';
|
|
11
|
+
import {
|
|
12
|
+
isMaxAccountConfigured,
|
|
13
|
+
MaxSecretConfigError,
|
|
14
|
+
type ResolvedMaxAccount,
|
|
15
|
+
resolveMaxAccountNetwork,
|
|
16
|
+
} from './accounts.js';
|
|
11
17
|
import { MaxApi } from './api.js';
|
|
12
18
|
import { startMaxPolling } from './monitor.js';
|
|
19
|
+
import { DEFAULT_MAX_API_BASE_URL, redactProxyUrl } from './network.js';
|
|
13
20
|
import { writeMaxConfig } from './runtime.js';
|
|
14
21
|
|
|
15
22
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
@@ -32,6 +39,7 @@ export const maxStatusAdapter: NonNullable<MaxChannelPlugin['status']> = {
|
|
|
32
39
|
// compares them with the live runtime and treats a missing value as stale.
|
|
33
40
|
...(snapshot.lifecycle !== undefined ? { lifecycle: snapshot.lifecycle } : {}),
|
|
34
41
|
...(typeof snapshot.connected === 'boolean' ? { connected: snapshot.connected } : {}),
|
|
42
|
+
...(snapshot.mode ? { mode: snapshot.mode } : {}),
|
|
35
43
|
lastStartAt: snapshot.lastStartAt ?? null,
|
|
36
44
|
lastStopAt: snapshot.lastStopAt ?? null,
|
|
37
45
|
lastError: snapshot.lastError ?? null,
|
|
@@ -53,11 +61,15 @@ export const maxStatusAdapter: NonNullable<MaxChannelPlugin['status']> = {
|
|
|
53
61
|
accountId: account.accountId,
|
|
54
62
|
name: account.name,
|
|
55
63
|
enabled: account.enabled,
|
|
56
|
-
configured:
|
|
64
|
+
configured: isMaxAccountConfigured(account),
|
|
57
65
|
tokenSource: account.tokenSource,
|
|
66
|
+
tokenStatus: account.tokenStatus,
|
|
67
|
+
...(account.stateReason ? { stateReason: account.stateReason } : {}),
|
|
58
68
|
running: runtime?.running ?? false,
|
|
59
69
|
...(runtime?.lifecycle !== undefined ? { lifecycle: runtime.lifecycle } : {}),
|
|
60
70
|
...(typeof runtime?.connected === 'boolean' ? { connected: runtime.connected } : {}),
|
|
71
|
+
// Transport the running task reported ("webhook" or "polling").
|
|
72
|
+
...(runtime?.mode ? { mode: runtime.mode } : {}),
|
|
61
73
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
62
74
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
63
75
|
lastError: runtime?.lastError ?? null,
|
|
@@ -163,7 +175,9 @@ export const maxStatusAdapter: NonNullable<MaxChannelPlugin['status']> = {
|
|
|
163
175
|
channel: 'max',
|
|
164
176
|
accountId: snapshot.accountId,
|
|
165
177
|
kind: 'config' as const,
|
|
166
|
-
message:
|
|
178
|
+
message: snapshot.stateReason
|
|
179
|
+
? `MAX bot token not configured: ${snapshot.stateReason}`
|
|
180
|
+
: 'MAX bot token not configured',
|
|
167
181
|
fix: 'Set channels.max.botToken or MAX_BOT_TOKEN env var',
|
|
168
182
|
});
|
|
169
183
|
}
|
|
@@ -177,13 +191,26 @@ export const maxStatusAdapter: NonNullable<MaxChannelPlugin['status']> = {
|
|
|
177
191
|
export const maxGatewayAdapter: NonNullable<MaxChannelPlugin['gateway']> = {
|
|
178
192
|
startAccount: async (ctx) => {
|
|
179
193
|
const account = ctx.account;
|
|
194
|
+
// Path and ref only; the gateway normally isolates such an account itself.
|
|
195
|
+
if (account.secretErrors?.botToken) {
|
|
196
|
+
throw new MaxSecretConfigError(account.secretErrors.botToken);
|
|
197
|
+
}
|
|
198
|
+
// A named account open only through channel-level (or default) policies.
|
|
199
|
+
const inheritedOpen = describeMaxInheritedOpenAccess(ctx.cfg, account);
|
|
200
|
+
if (inheritedOpen) ctx.log?.warn(`[${account.accountId}] ${inheritedOpen}`);
|
|
180
201
|
const token = account.token.trim();
|
|
202
|
+
// A bad apiBaseUrl/httpProxy stops the account with a clear error rather
|
|
203
|
+
// than letting it connect directly or to the wrong host.
|
|
204
|
+
const network = resolveMaxAccountNetwork(account);
|
|
205
|
+
const route =
|
|
206
|
+
(network.apiBaseUrl !== DEFAULT_MAX_API_BASE_URL ? ` api=${network.apiBaseUrl}` : '') +
|
|
207
|
+
(network.proxyUrl ? ` proxy=${redactProxyUrl(network.proxyUrl)}` : '');
|
|
181
208
|
|
|
182
209
|
let botLabel = '';
|
|
183
210
|
let botUserId: number | undefined;
|
|
184
211
|
let botUsername: string | undefined;
|
|
185
212
|
try {
|
|
186
|
-
const probeApi = new MaxApi({ token, timeoutMs: 3000 });
|
|
213
|
+
const probeApi = new MaxApi({ token, timeoutMs: 3000, network });
|
|
187
214
|
const me = await probeApi.getMe();
|
|
188
215
|
if (me.username) {
|
|
189
216
|
botLabel = ` (@${me.username})`;
|
|
@@ -194,9 +221,9 @@ export const maxGatewayAdapter: NonNullable<MaxChannelPlugin['gateway']> = {
|
|
|
194
221
|
// probe failed, continue anyway
|
|
195
222
|
}
|
|
196
223
|
|
|
197
|
-
ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}`);
|
|
224
|
+
ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}${route}`);
|
|
198
225
|
|
|
199
|
-
const api = new MaxApi({ token });
|
|
226
|
+
const api = new MaxApi({ token, network });
|
|
200
227
|
|
|
201
228
|
// Register bot commands if configured
|
|
202
229
|
const commands = ctx.cfg.channels?.max?.commands as
|
package/src/channel-outbound.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
|
|
7
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
|
|
7
9
|
|
|
8
10
|
import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
9
11
|
import {
|
|
@@ -11,11 +13,14 @@ import {
|
|
|
11
13
|
MAX_PRESENTATION_CAPABILITIES,
|
|
12
14
|
MAX_TEXT_LIMIT,
|
|
13
15
|
renderMaxPresentation,
|
|
16
|
+
resolveMaxTextChunkLimit,
|
|
14
17
|
} from './presentation.js';
|
|
15
18
|
import { getMaxRuntime, loadMaxConfig } from './runtime.js';
|
|
16
19
|
import {
|
|
20
|
+
type MaxLocalMediaAccess,
|
|
17
21
|
pinMaxMessage,
|
|
18
22
|
readMaxChannelButtons,
|
|
23
|
+
resolveMaxSendFlags,
|
|
19
24
|
sendMaxMediaGroup,
|
|
20
25
|
sendMaxMediaMessage,
|
|
21
26
|
sendMaxMessage,
|
|
@@ -23,10 +28,31 @@ import {
|
|
|
23
28
|
|
|
24
29
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
25
30
|
|
|
26
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Local media access for an outbound send: the roots and host reader core
|
|
33
|
+
* passed with the delivery; without them the default media roots (no agent
|
|
34
|
+
* workspace is known here). Local files are read only through this policy.
|
|
35
|
+
*/
|
|
36
|
+
function resolveOutboundLocalMedia(
|
|
37
|
+
cfg: OpenClawConfig,
|
|
38
|
+
ctx: MaxLocalMediaAccess,
|
|
39
|
+
): MaxLocalMediaAccess {
|
|
40
|
+
if (ctx.mediaAccess || ctx.mediaLocalRoots?.length || ctx.mediaReadFile) {
|
|
41
|
+
return {
|
|
42
|
+
mediaAccess: ctx.mediaAccess,
|
|
43
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
44
|
+
mediaReadFile: ctx.mediaReadFile,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Direct delivery with markdown chunks of at most 4000 characters. */
|
|
27
51
|
export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
28
52
|
deliveryMode: 'direct',
|
|
29
|
-
|
|
53
|
+
// Core passes channels.max.textChunkLimit here; MAX rejects anything longer than 4000.
|
|
54
|
+
chunker: (text, limit) =>
|
|
55
|
+
getMaxRuntime().channel.text.chunkMarkdownText(text, Math.min(limit, MAX_TEXT_LIMIT)),
|
|
30
56
|
chunkerMode: 'markdown',
|
|
31
57
|
textChunkLimit: 4000,
|
|
32
58
|
|
|
@@ -49,7 +75,8 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
49
75
|
});
|
|
50
76
|
},
|
|
51
77
|
|
|
52
|
-
sendPayload: async (
|
|
78
|
+
sendPayload: async (ctx) => {
|
|
79
|
+
const { to, text, payload: rawPayload, mediaUrl, accountId, replyToId } = ctx;
|
|
53
80
|
const cfg = await loadMaxConfig();
|
|
54
81
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
55
82
|
if (!account.token) throw new Error('MAX bot token not configured');
|
|
@@ -61,6 +88,10 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
61
88
|
: rawPayload;
|
|
62
89
|
const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
|
|
63
90
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
91
|
+
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
92
|
+
silent: ctx.silent,
|
|
93
|
+
channelData: payload.channelData,
|
|
94
|
+
});
|
|
64
95
|
const mediaUrls = mediaUrl
|
|
65
96
|
? [mediaUrl]
|
|
66
97
|
: payload.mediaUrls?.length
|
|
@@ -77,7 +108,10 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
77
108
|
replyToMessageId: replyToId ?? undefined,
|
|
78
109
|
format: 'markdown',
|
|
79
110
|
buttons,
|
|
111
|
+
...sendFlags,
|
|
80
112
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
113
|
+
localMedia: resolveOutboundLocalMedia(cfg, ctx),
|
|
114
|
+
asVoice: ctx.audioAsVoice === true || payload.audioAsVoice === true,
|
|
81
115
|
});
|
|
82
116
|
return {
|
|
83
117
|
channel: 'max',
|
|
@@ -87,9 +121,10 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
87
121
|
|
|
88
122
|
// MAX caps a message at 4000 characters: split, keyboard on the last
|
|
89
123
|
// chunk, report the first one (delivery.pin pins the first chunk).
|
|
124
|
+
const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
|
|
90
125
|
const chunks =
|
|
91
|
-
effectiveText.length >
|
|
92
|
-
? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText,
|
|
126
|
+
effectiveText.length > chunkLimit
|
|
127
|
+
? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, chunkLimit)
|
|
93
128
|
: [effectiveText];
|
|
94
129
|
let firstMessageId = '';
|
|
95
130
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
@@ -98,6 +133,7 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
98
133
|
replyToMessageId: index === 0 ? (replyToId ?? undefined) : undefined,
|
|
99
134
|
format: 'markdown',
|
|
100
135
|
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
136
|
+
...sendFlags,
|
|
101
137
|
});
|
|
102
138
|
if (index === 0) firstMessageId = result.messageId;
|
|
103
139
|
}
|
|
@@ -108,7 +144,7 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
108
144
|
};
|
|
109
145
|
},
|
|
110
146
|
|
|
111
|
-
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
147
|
+
sendText: async ({ to, text, accountId, replyToId, silent }) => {
|
|
112
148
|
const cfg = await loadMaxConfig();
|
|
113
149
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
114
150
|
if (!account.token) throw new Error('MAX bot token not configured');
|
|
@@ -117,6 +153,7 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
117
153
|
token: account.token,
|
|
118
154
|
replyToMessageId: replyToId ?? undefined,
|
|
119
155
|
format: 'markdown',
|
|
156
|
+
...resolveMaxSendFlags(account.config, { silent }),
|
|
120
157
|
});
|
|
121
158
|
|
|
122
159
|
return {
|
|
@@ -125,10 +162,12 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
125
162
|
};
|
|
126
163
|
},
|
|
127
164
|
|
|
128
|
-
sendMedia: async (
|
|
165
|
+
sendMedia: async (ctx) => {
|
|
166
|
+
const { to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
129
167
|
const cfg = await loadMaxConfig();
|
|
130
168
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
131
169
|
if (!account.token) throw new Error('MAX bot token not configured');
|
|
170
|
+
const sendFlags = resolveMaxSendFlags(account.config, { silent: ctx.silent });
|
|
132
171
|
|
|
133
172
|
if (!mediaUrl) {
|
|
134
173
|
// No media, send as text
|
|
@@ -136,6 +175,7 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
136
175
|
token: account.token,
|
|
137
176
|
replyToMessageId: replyToId ?? undefined,
|
|
138
177
|
format: 'markdown',
|
|
178
|
+
...sendFlags,
|
|
139
179
|
});
|
|
140
180
|
return {
|
|
141
181
|
channel: 'max',
|
|
@@ -148,7 +188,10 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
148
188
|
token: account.token,
|
|
149
189
|
replyToMessageId: replyToId ?? undefined,
|
|
150
190
|
format: 'markdown',
|
|
191
|
+
...sendFlags,
|
|
151
192
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
193
|
+
localMedia: resolveOutboundLocalMedia(cfg, ctx),
|
|
194
|
+
asVoice: ctx.audioAsVoice === true,
|
|
152
195
|
});
|
|
153
196
|
|
|
154
197
|
return {
|
package/src/channel-policy.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk/channel-status';
|
|
|
10
10
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
11
11
|
import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from 'openclaw/plugin-sdk/core';
|
|
12
12
|
|
|
13
|
-
import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
13
|
+
import { readMaxAccount, type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
14
14
|
import { sendMaxMessage } from './send.js';
|
|
15
15
|
|
|
16
16
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
@@ -109,10 +109,26 @@ function resolveMaxGroupToolPolicy(params: {
|
|
|
109
109
|
return undefined;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Account config for the security adapter. Since OpenClaw 2026.9.7 `security audit`
|
|
114
|
+
* (and doctor) hand the adapter the read-only `config.inspectAccount` status, which
|
|
115
|
+
* carries no `config` — re-read the account section from cfg (side-effect free) in that case.
|
|
116
|
+
*/
|
|
117
|
+
function resolveMaxPolicyAccountConfig(
|
|
118
|
+
cfg: OpenClawConfig,
|
|
119
|
+
accountId: string,
|
|
120
|
+
account: Pick<ResolvedMaxAccount, 'config'> | { config?: undefined } | null | undefined,
|
|
121
|
+
): ResolvedMaxAccount['config'] {
|
|
122
|
+
const config = (account as { config?: ResolvedMaxAccount['config'] } | null | undefined)?.config;
|
|
123
|
+
if (config && typeof config === 'object') return config;
|
|
124
|
+
return readMaxAccount({ cfg, accountId }).config;
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/** DM policy (default pairing) and warnings for open group policy. */
|
|
113
128
|
export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
114
129
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
115
|
-
const resolvedAccountId = accountId ?? account
|
|
130
|
+
const resolvedAccountId = accountId ?? account?.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
131
|
+
const config = resolveMaxPolicyAccountConfig(cfg, resolvedAccountId, account);
|
|
116
132
|
const maxSection = (cfg.channels as Record<string, unknown>)?.max as
|
|
117
133
|
Record<string, unknown> | undefined;
|
|
118
134
|
const useAccountPath = Boolean(
|
|
@@ -122,8 +138,8 @@ export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
|
122
138
|
? `channels.max.accounts.${resolvedAccountId}.`
|
|
123
139
|
: 'channels.max.';
|
|
124
140
|
return {
|
|
125
|
-
policy:
|
|
126
|
-
allowFrom:
|
|
141
|
+
policy: config.dmPolicy ?? 'pairing',
|
|
142
|
+
allowFrom: config.allowFrom ?? [],
|
|
127
143
|
policyPath: `${basePath}dmPolicy`,
|
|
128
144
|
allowFromPath: basePath,
|
|
129
145
|
approveHint: formatPairingApproveHint('max'),
|
|
@@ -131,13 +147,17 @@ export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
|
|
|
131
147
|
};
|
|
132
148
|
},
|
|
133
149
|
collectWarnings: ({ account, cfg }) => {
|
|
150
|
+
const config = resolveMaxPolicyAccountConfig(
|
|
151
|
+
cfg,
|
|
152
|
+
account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
153
|
+
account,
|
|
154
|
+
);
|
|
134
155
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
135
|
-
const groupPolicy =
|
|
156
|
+
const groupPolicy = config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
|
136
157
|
if (groupPolicy !== 'open') {
|
|
137
158
|
return [];
|
|
138
159
|
}
|
|
139
|
-
const groupAllowlistConfigured =
|
|
140
|
-
account.config.groups && Object.keys(account.config.groups).length > 0;
|
|
160
|
+
const groupAllowlistConfigured = config.groups && Object.keys(config.groups).length > 0;
|
|
141
161
|
if (groupAllowlistConfigured) {
|
|
142
162
|
return [
|
|
143
163
|
`- MAX groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.max.groupPolicy="allowlist" + channels.max.groupAllowFrom to restrict senders.`,
|
package/src/channel.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
buildMaxModelsProviderChannelData,
|
|
30
30
|
} from './model-buttons.js';
|
|
31
31
|
import { maxSetupWizard } from './onboarding.js';
|
|
32
|
+
import { maxChannelSecrets } from './secret-contract.js';
|
|
33
|
+
import { sendMaxHeartbeatTyping } from './typing.js';
|
|
32
34
|
|
|
33
35
|
// ── Meta ──
|
|
34
36
|
|
|
@@ -64,6 +66,16 @@ export const maxPlugin: ChannelPlugin<ResolvedMaxAccount> = {
|
|
|
64
66
|
unsend: true,
|
|
65
67
|
reply: true,
|
|
66
68
|
polls: false,
|
|
69
|
+
// TTS replies: core keeps its normal audio-file synthesis (MP3 unless the
|
|
70
|
+
// provider is set otherwise) and marks audio in these formats as voice.
|
|
71
|
+
// MAX documents MP3/WAV/M4A for audio and records its own voice messages
|
|
72
|
+
// as Ogg/Opus; no transcoding on either side.
|
|
73
|
+
tts: {
|
|
74
|
+
voice: {
|
|
75
|
+
synthesisTarget: 'audio-file',
|
|
76
|
+
audioFileFormats: ['mp3', 'm4a', 'wav', 'ogg', 'opus'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
67
79
|
},
|
|
68
80
|
|
|
69
81
|
reload: { configPrefixes: ['channels.max'] },
|
|
@@ -91,8 +103,12 @@ export const maxPlugin: ChannelPlugin<ResolvedMaxAccount> = {
|
|
|
91
103
|
directory: maxDirectoryAdapter,
|
|
92
104
|
outbound: maxOutboundAdapter,
|
|
93
105
|
setup: maxSetupAdapter,
|
|
106
|
+
// botToken, webhookSecret, httpProxy accept SecretRefs (also exported as
|
|
107
|
+
// dist/secret-contract-api.js for the gateway's pre-runtime resolution).
|
|
108
|
+
secrets: maxChannelSecrets,
|
|
94
109
|
status: maxStatusAdapter,
|
|
95
110
|
gateway: maxGatewayAdapter,
|
|
111
|
+
heartbeat: { sendTyping: sendMaxHeartbeatTyping, sendTypingGuarded: sendMaxHeartbeatTyping },
|
|
96
112
|
|
|
97
113
|
// Message actions (send, edit, delete)
|
|
98
114
|
actions: maxMessageActions,
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument menus for core commands. A bare command whose core definition
|
|
3
|
+
* declares an argument menu (argsMenu: /think, /fast, /reasoning, /verbose,
|
|
4
|
+
* /usage, /tts, …) gets its choices as buttons; a press re-enters the inbound
|
|
5
|
+
* pipeline as that command's text, so it is applied exactly like typing it.
|
|
6
|
+
*
|
|
7
|
+
* Choices, titles and command text come from the core command registry;
|
|
8
|
+
* session state is only read through the runtime session API. Nothing here
|
|
9
|
+
* changes the session or the config: the chosen command does, in core.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildCommandTextFromArgs,
|
|
14
|
+
type ChatCommandDefinition,
|
|
15
|
+
type CommandArgs,
|
|
16
|
+
findCommandByNativeName,
|
|
17
|
+
formatCommandArgMenuTitle,
|
|
18
|
+
formatFastModeCurrentStatus,
|
|
19
|
+
listChatCommands,
|
|
20
|
+
normalizeCommandBody,
|
|
21
|
+
parseCommandArgs,
|
|
22
|
+
resolveCommandArgChoices,
|
|
23
|
+
resolveCommandArgMenu,
|
|
24
|
+
resolveCommandAuthorization,
|
|
25
|
+
resolveEffectiveAgentRuntime,
|
|
26
|
+
resolveFastModeState,
|
|
27
|
+
resolveStoredModelOverride,
|
|
28
|
+
} from 'openclaw/plugin-sdk/command-auth-native';
|
|
29
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
30
|
+
|
|
31
|
+
import { MAX_CALLBACK_PAYLOAD_BYTES } from './presentation.js';
|
|
32
|
+
import { getMaxRuntime } from './runtime.js';
|
|
33
|
+
|
|
34
|
+
type MaxButton = { text: string; payload: string };
|
|
35
|
+
|
|
36
|
+
type SessionEntry = NonNullable<
|
|
37
|
+
ReturnType<ReturnType<typeof getMaxRuntime>['agent']['session']['getSessionEntry']>
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
/** Private envelope of a menu button: the command text of one choice. */
|
|
41
|
+
const COMMAND_MENU_PREFIX = 'mxcmd1:';
|
|
42
|
+
|
|
43
|
+
/** Session fields whose stored value is one of the command's choice values. */
|
|
44
|
+
const SESSION_CHOICE_FIELDS: Record<string, keyof SessionEntry> = {
|
|
45
|
+
think: 'thinkingLevel',
|
|
46
|
+
verbose: 'verboseLevel',
|
|
47
|
+
reasoning: 'reasoningLevel',
|
|
48
|
+
elevated: 'elevatedLevel',
|
|
49
|
+
trace: 'traceLevel',
|
|
50
|
+
usage: 'responseUsage',
|
|
51
|
+
activation: 'groupActivation',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type MaxCommandMenuDecision =
|
|
55
|
+
/** Not a menu case: the message goes to core as usual. */
|
|
56
|
+
| { kind: 'dispatch' }
|
|
57
|
+
/** Show (or, for a press, replace the pressed menu with) this menu. */
|
|
58
|
+
| { kind: 'menu'; text: string; buttons: MaxButton[][] }
|
|
59
|
+
/** Press by a sender without command rights. */
|
|
60
|
+
| { kind: 'denied' }
|
|
61
|
+
/** Press whose command or choice core no longer offers. */
|
|
62
|
+
| { kind: 'stale' };
|
|
63
|
+
|
|
64
|
+
export function encodeMaxCommandMenuPayload(commandText: string): string | null {
|
|
65
|
+
const payload = `${COMMAND_MENU_PREFIX}${commandText}`;
|
|
66
|
+
return Buffer.byteLength(payload, 'utf8') <= MAX_CALLBACK_PAYLOAD_BYTES ? payload : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Command text of a menu button press; null for any other payload. */
|
|
70
|
+
export function decodeMaxCommandMenuPayload(payload: string | undefined | null): string | null {
|
|
71
|
+
if (!payload?.startsWith(COMMAND_MENU_PREFIX)) return null;
|
|
72
|
+
const text = payload.slice(COMMAND_MENU_PREFIX.length).trim();
|
|
73
|
+
return text.startsWith('/') ? text : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A slash command whose core definition has an argument menu; null otherwise. */
|
|
77
|
+
function parseMenuCommand(
|
|
78
|
+
text: string,
|
|
79
|
+
botUsername: string | undefined,
|
|
80
|
+
): { command: ChatCommandDefinition; args?: CommandArgs } | null {
|
|
81
|
+
const normalized = normalizeCommandBody(text.trim(), botUsername ? { botUsername } : undefined);
|
|
82
|
+
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/u.exec(normalized);
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
const name = match[1].toLowerCase();
|
|
85
|
+
const command =
|
|
86
|
+
findCommandByNativeName(name, 'max') ??
|
|
87
|
+
listChatCommands().find(
|
|
88
|
+
(entry) => entry.key === name || entry.textAliases.includes(`/${name}`),
|
|
89
|
+
);
|
|
90
|
+
if (!command?.args?.length || !command.argsMenu) return null;
|
|
91
|
+
return { command, args: parseCommandArgs(command, match[2]?.trim() || undefined) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type MenuModelContext = {
|
|
95
|
+
entry?: SessionEntry;
|
|
96
|
+
provider?: string;
|
|
97
|
+
model?: string;
|
|
98
|
+
agentRuntime?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The session's model (dynamic choices such as /think levels depend on it). */
|
|
102
|
+
function resolveMenuModelContext(params: {
|
|
103
|
+
cfg: OpenClawConfig;
|
|
104
|
+
agentId: string;
|
|
105
|
+
sessionKey: string;
|
|
106
|
+
}): MenuModelContext {
|
|
107
|
+
const core = getMaxRuntime();
|
|
108
|
+
const { cfg, agentId, sessionKey } = params;
|
|
109
|
+
try {
|
|
110
|
+
const load = (key: string) => core.agent.session.getSessionEntry({ agentId, sessionKey: key });
|
|
111
|
+
const entry = load(sessionKey);
|
|
112
|
+
const defaults = core.modelConfig.resolveDefaultModelForAgent({ cfg, agentId });
|
|
113
|
+
// An automatic fallback override is temporary: menus follow the default.
|
|
114
|
+
const override =
|
|
115
|
+
entry?.modelOverrideSource === 'auto'
|
|
116
|
+
? null
|
|
117
|
+
: resolveStoredModelOverride({
|
|
118
|
+
sessionEntry: entry,
|
|
119
|
+
loadSessionEntry: load,
|
|
120
|
+
sessionKey,
|
|
121
|
+
defaultProvider: defaults.provider,
|
|
122
|
+
});
|
|
123
|
+
const provider = override?.provider ?? defaults.provider;
|
|
124
|
+
const model = override?.model ?? defaults.model;
|
|
125
|
+
let agentRuntime: string | undefined;
|
|
126
|
+
try {
|
|
127
|
+
agentRuntime = resolveEffectiveAgentRuntime({
|
|
128
|
+
cfg,
|
|
129
|
+
provider,
|
|
130
|
+
modelId: model,
|
|
131
|
+
agentId,
|
|
132
|
+
sessionKey,
|
|
133
|
+
sessionEntry: entry,
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
agentRuntime = undefined;
|
|
137
|
+
}
|
|
138
|
+
return { entry, provider, model, agentRuntime };
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every argument value of a pressed command is still among core's choices. */
|
|
145
|
+
function isPressStillOffered(
|
|
146
|
+
command: ChatCommandDefinition,
|
|
147
|
+
args: CommandArgs | undefined,
|
|
148
|
+
cfg: OpenClawConfig,
|
|
149
|
+
context: MenuModelContext,
|
|
150
|
+
): boolean {
|
|
151
|
+
const values = args?.values ?? {};
|
|
152
|
+
for (const arg of command.args ?? []) {
|
|
153
|
+
const value = values[arg.name];
|
|
154
|
+
if (value == null || !arg.choices) continue;
|
|
155
|
+
const choices = resolveCommandArgChoices({
|
|
156
|
+
command,
|
|
157
|
+
arg,
|
|
158
|
+
cfg,
|
|
159
|
+
provider: context.provider,
|
|
160
|
+
model: context.model,
|
|
161
|
+
agentRuntime: context.agentRuntime,
|
|
162
|
+
});
|
|
163
|
+
if (choices.length > 0 && !choices.some((choice) => choice.value === String(value))) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveCurrentChoice(
|
|
171
|
+
command: ChatCommandDefinition,
|
|
172
|
+
context: MenuModelContext,
|
|
173
|
+
fastState: ReturnType<typeof resolveFastModeState> | undefined,
|
|
174
|
+
): string | undefined {
|
|
175
|
+
if (fastState) {
|
|
176
|
+
if (fastState.source !== 'session') return 'default';
|
|
177
|
+
return fastState.mode === 'auto' ? 'auto' : fastState.mode ? 'on' : 'off';
|
|
178
|
+
}
|
|
179
|
+
const field = SESSION_CHOICE_FIELDS[command.key];
|
|
180
|
+
const stored = field ? context.entry?.[field] : undefined;
|
|
181
|
+
if (typeof stored === 'string' && stored) return stored;
|
|
182
|
+
// Nothing stored for this session: the "default" choice is what applies.
|
|
183
|
+
return 'default';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decide what a slash command (typed, or pressed on a menu when `press`) does:
|
|
188
|
+
* open its argument menu, go to core unchanged, or — presses only — be refused
|
|
189
|
+
* for lack of command rights or as stale. `ctx` is the finalized inbound
|
|
190
|
+
* context of the message, so command rights are resolved exactly as core
|
|
191
|
+
* resolves them for the same text.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveMaxCommandMenu(params: {
|
|
194
|
+
text: string;
|
|
195
|
+
press: boolean;
|
|
196
|
+
ctx: Parameters<typeof resolveCommandAuthorization>[0]['ctx'];
|
|
197
|
+
cfg: OpenClawConfig;
|
|
198
|
+
agentId: string;
|
|
199
|
+
sessionKey: string;
|
|
200
|
+
botUsername?: string;
|
|
201
|
+
}): MaxCommandMenuDecision {
|
|
202
|
+
const { press, cfg, agentId, sessionKey } = params;
|
|
203
|
+
const pass: MaxCommandMenuDecision = press ? { kind: 'stale' } : { kind: 'dispatch' };
|
|
204
|
+
const parsed = parseMenuCommand(params.text, params.botUsername);
|
|
205
|
+
if (!parsed) return pass;
|
|
206
|
+
const { command, args } = parsed;
|
|
207
|
+
|
|
208
|
+
const core = getMaxRuntime();
|
|
209
|
+
if (
|
|
210
|
+
!core.channel.commands.shouldHandleTextCommands({ cfg, surface: 'max', commandSource: 'text' })
|
|
211
|
+
) {
|
|
212
|
+
return pass;
|
|
213
|
+
}
|
|
214
|
+
// Core authorizes MAX text commands with CommandAuthorized unset (false).
|
|
215
|
+
const authorization = resolveCommandAuthorization({
|
|
216
|
+
ctx: params.ctx,
|
|
217
|
+
cfg,
|
|
218
|
+
commandAuthorized: params.ctx.CommandAuthorized === true,
|
|
219
|
+
});
|
|
220
|
+
if (!authorization.isAuthorizedSender) {
|
|
221
|
+
return press ? { kind: 'denied' } : { kind: 'dispatch' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const context = resolveMenuModelContext({ cfg, agentId, sessionKey });
|
|
225
|
+
if (press && !isPressStillOffered(command, args, cfg, context)) return { kind: 'stale' };
|
|
226
|
+
|
|
227
|
+
const menu = resolveCommandArgMenu({
|
|
228
|
+
command,
|
|
229
|
+
args,
|
|
230
|
+
cfg,
|
|
231
|
+
provider: context.provider,
|
|
232
|
+
model: context.model,
|
|
233
|
+
agentRuntime: context.agentRuntime,
|
|
234
|
+
session: { agentId, sessionKey },
|
|
235
|
+
});
|
|
236
|
+
if (!menu) return { kind: 'dispatch' };
|
|
237
|
+
|
|
238
|
+
const fastState =
|
|
239
|
+
command.key === 'fast' && context.provider && context.model
|
|
240
|
+
? resolveFastModeState({
|
|
241
|
+
cfg,
|
|
242
|
+
provider: context.provider,
|
|
243
|
+
model: context.model,
|
|
244
|
+
agentId,
|
|
245
|
+
sessionEntry:
|
|
246
|
+
context.entry?.fastMode !== undefined
|
|
247
|
+
? { fastMode: context.entry.fastMode }
|
|
248
|
+
: undefined,
|
|
249
|
+
})
|
|
250
|
+
: undefined;
|
|
251
|
+
const current = resolveCurrentChoice(command, context, fastState);
|
|
252
|
+
|
|
253
|
+
let title = formatCommandArgMenuTitle({ command, menu });
|
|
254
|
+
if (fastState) {
|
|
255
|
+
title = `${formatFastModeCurrentStatus(fastState)}\n${title}`;
|
|
256
|
+
} else if (command.key === 'think' && current !== 'default') {
|
|
257
|
+
title = `Current thinking level: ${current}.\n${title}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const buttons: MaxButton[] = [];
|
|
261
|
+
for (const choice of menu.choices) {
|
|
262
|
+
const payload = encodeMaxCommandMenuPayload(
|
|
263
|
+
buildCommandTextFromArgs(command, {
|
|
264
|
+
values: { ...args?.values, [menu.arg.name]: choice.value },
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
if (!payload) continue;
|
|
268
|
+
buttons.push({ text: choice.value === current ? `${choice.label} ✓` : choice.label, payload });
|
|
269
|
+
}
|
|
270
|
+
if (buttons.length === 0) return { kind: 'dispatch' };
|
|
271
|
+
|
|
272
|
+
const rows: MaxButton[][] = [];
|
|
273
|
+
for (let index = 0; index < buttons.length; index += 2) {
|
|
274
|
+
rows.push(buttons.slice(index, index + 2));
|
|
275
|
+
}
|
|
276
|
+
return { kind: 'menu', text: title, buttons: rows };
|
|
277
|
+
}
|