@aspalagin/openclaw-max 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
|
@@ -2,14 +2,31 @@
|
|
|
2
2
|
* Outbound adapter of the MAX channel: sendText / sendPayload / sendMedia,
|
|
3
3
|
* presentation rendering and delivery pins for core's outbound path.
|
|
4
4
|
*/
|
|
5
|
+
import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
|
|
5
6
|
import { resolveMaxAccount } from './accounts.js';
|
|
6
|
-
import { materializeMaxPresentation, MAX_PRESENTATION_CAPABILITIES, MAX_TEXT_LIMIT, renderMaxPresentation, } from './presentation.js';
|
|
7
|
+
import { materializeMaxPresentation, MAX_PRESENTATION_CAPABILITIES, MAX_TEXT_LIMIT, renderMaxPresentation, resolveMaxTextChunkLimit, } from './presentation.js';
|
|
7
8
|
import { getMaxRuntime, loadMaxConfig } from './runtime.js';
|
|
8
|
-
import { pinMaxMessage, readMaxChannelButtons, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, } from './send.js';
|
|
9
|
-
/**
|
|
9
|
+
import { pinMaxMessage, readMaxChannelButtons, resolveMaxSendFlags, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, } from './send.js';
|
|
10
|
+
/**
|
|
11
|
+
* Local media access for an outbound send: the roots and host reader core
|
|
12
|
+
* passed with the delivery; without them the default media roots (no agent
|
|
13
|
+
* workspace is known here). Local files are read only through this policy.
|
|
14
|
+
*/
|
|
15
|
+
function resolveOutboundLocalMedia(cfg, ctx) {
|
|
16
|
+
if (ctx.mediaAccess || ctx.mediaLocalRoots?.length || ctx.mediaReadFile) {
|
|
17
|
+
return {
|
|
18
|
+
mediaAccess: ctx.mediaAccess,
|
|
19
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
20
|
+
mediaReadFile: ctx.mediaReadFile,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg) };
|
|
24
|
+
}
|
|
25
|
+
/** Direct delivery with markdown chunks of at most 4000 characters. */
|
|
10
26
|
export const maxOutboundAdapter = {
|
|
11
27
|
deliveryMode: 'direct',
|
|
12
|
-
|
|
28
|
+
// Core passes channels.max.textChunkLimit here; MAX rejects anything longer than 4000.
|
|
29
|
+
chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, Math.min(limit, MAX_TEXT_LIMIT)),
|
|
13
30
|
chunkerMode: 'markdown',
|
|
14
31
|
textChunkLimit: 4000,
|
|
15
32
|
// Presentation (docs/plugins/message-presentation.md): core adapts to these
|
|
@@ -31,7 +48,8 @@ export const maxOutboundAdapter = {
|
|
|
31
48
|
beforeRequest: assertDirectAdapterHandoff,
|
|
32
49
|
});
|
|
33
50
|
},
|
|
34
|
-
sendPayload: async (
|
|
51
|
+
sendPayload: async (ctx) => {
|
|
52
|
+
const { to, text, payload: rawPayload, mediaUrl, accountId, replyToId } = ctx;
|
|
35
53
|
const cfg = await loadMaxConfig();
|
|
36
54
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
37
55
|
if (!account.token)
|
|
@@ -43,6 +61,10 @@ export const maxOutboundAdapter = {
|
|
|
43
61
|
: rawPayload;
|
|
44
62
|
const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
|
|
45
63
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
64
|
+
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
65
|
+
silent: ctx.silent,
|
|
66
|
+
channelData: payload.channelData,
|
|
67
|
+
});
|
|
46
68
|
const mediaUrls = mediaUrl
|
|
47
69
|
? [mediaUrl]
|
|
48
70
|
: payload.mediaUrls?.length
|
|
@@ -58,7 +80,10 @@ export const maxOutboundAdapter = {
|
|
|
58
80
|
replyToMessageId: replyToId ?? undefined,
|
|
59
81
|
format: 'markdown',
|
|
60
82
|
buttons,
|
|
83
|
+
...sendFlags,
|
|
61
84
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
85
|
+
localMedia: resolveOutboundLocalMedia(cfg, ctx),
|
|
86
|
+
asVoice: ctx.audioAsVoice === true || payload.audioAsVoice === true,
|
|
62
87
|
});
|
|
63
88
|
return {
|
|
64
89
|
channel: 'max',
|
|
@@ -67,8 +92,9 @@ export const maxOutboundAdapter = {
|
|
|
67
92
|
}
|
|
68
93
|
// MAX caps a message at 4000 characters: split, keyboard on the last
|
|
69
94
|
// chunk, report the first one (delivery.pin pins the first chunk).
|
|
70
|
-
const
|
|
71
|
-
|
|
95
|
+
const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
|
|
96
|
+
const chunks = effectiveText.length > chunkLimit
|
|
97
|
+
? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, chunkLimit)
|
|
72
98
|
: [effectiveText];
|
|
73
99
|
let firstMessageId = '';
|
|
74
100
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
@@ -77,6 +103,7 @@ export const maxOutboundAdapter = {
|
|
|
77
103
|
replyToMessageId: index === 0 ? (replyToId ?? undefined) : undefined,
|
|
78
104
|
format: 'markdown',
|
|
79
105
|
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
106
|
+
...sendFlags,
|
|
80
107
|
});
|
|
81
108
|
if (index === 0)
|
|
82
109
|
firstMessageId = result.messageId;
|
|
@@ -86,7 +113,7 @@ export const maxOutboundAdapter = {
|
|
|
86
113
|
messageId: firstMessageId,
|
|
87
114
|
};
|
|
88
115
|
},
|
|
89
|
-
sendText: async ({ to, text, accountId, replyToId }) => {
|
|
116
|
+
sendText: async ({ to, text, accountId, replyToId, silent }) => {
|
|
90
117
|
const cfg = await loadMaxConfig();
|
|
91
118
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
92
119
|
if (!account.token)
|
|
@@ -95,23 +122,27 @@ export const maxOutboundAdapter = {
|
|
|
95
122
|
token: account.token,
|
|
96
123
|
replyToMessageId: replyToId ?? undefined,
|
|
97
124
|
format: 'markdown',
|
|
125
|
+
...resolveMaxSendFlags(account.config, { silent }),
|
|
98
126
|
});
|
|
99
127
|
return {
|
|
100
128
|
channel: 'max',
|
|
101
129
|
messageId: result.messageId,
|
|
102
130
|
};
|
|
103
131
|
},
|
|
104
|
-
sendMedia: async (
|
|
132
|
+
sendMedia: async (ctx) => {
|
|
133
|
+
const { to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
105
134
|
const cfg = await loadMaxConfig();
|
|
106
135
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
107
136
|
if (!account.token)
|
|
108
137
|
throw new Error('MAX bot token not configured');
|
|
138
|
+
const sendFlags = resolveMaxSendFlags(account.config, { silent: ctx.silent });
|
|
109
139
|
if (!mediaUrl) {
|
|
110
140
|
// No media, send as text
|
|
111
141
|
const result = await sendMaxMessage(to, text, {
|
|
112
142
|
token: account.token,
|
|
113
143
|
replyToMessageId: replyToId ?? undefined,
|
|
114
144
|
format: 'markdown',
|
|
145
|
+
...sendFlags,
|
|
115
146
|
});
|
|
116
147
|
return {
|
|
117
148
|
channel: 'max',
|
|
@@ -123,7 +154,10 @@ export const maxOutboundAdapter = {
|
|
|
123
154
|
token: account.token,
|
|
124
155
|
replyToMessageId: replyToId ?? undefined,
|
|
125
156
|
format: 'markdown',
|
|
157
|
+
...sendFlags,
|
|
126
158
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
159
|
+
localMedia: resolveOutboundLocalMedia(cfg, ctx),
|
|
160
|
+
asVoice: ctx.audioAsVoice === true,
|
|
127
161
|
});
|
|
128
162
|
return {
|
|
129
163
|
channel: 'max',
|
package/dist/src/channel.js
CHANGED
|
@@ -18,6 +18,8 @@ import { maxGroupsAdapter, maxPairingAdapter, maxSecurityAdapter } from './chann
|
|
|
18
18
|
import { MaxConfigSchema } from './config-schema.js';
|
|
19
19
|
import { buildMaxModelBrowseChannelData, buildMaxModelsAddProviderChannelData, buildMaxModelsListChannelData, buildMaxModelsMenuChannelData, buildMaxModelsProviderChannelData, } from './model-buttons.js';
|
|
20
20
|
import { maxSetupWizard } from './onboarding.js';
|
|
21
|
+
import { maxChannelSecrets } from './secret-contract.js';
|
|
22
|
+
import { sendMaxHeartbeatTyping } from './typing.js';
|
|
21
23
|
// ── Meta ──
|
|
22
24
|
const maxMeta = {
|
|
23
25
|
id: 'max',
|
|
@@ -46,6 +48,16 @@ export const maxPlugin = {
|
|
|
46
48
|
unsend: true,
|
|
47
49
|
reply: true,
|
|
48
50
|
polls: false,
|
|
51
|
+
// TTS replies: core keeps its normal audio-file synthesis (MP3 unless the
|
|
52
|
+
// provider is set otherwise) and marks audio in these formats as voice.
|
|
53
|
+
// MAX documents MP3/WAV/M4A for audio and records its own voice messages
|
|
54
|
+
// as Ogg/Opus; no transcoding on either side.
|
|
55
|
+
tts: {
|
|
56
|
+
voice: {
|
|
57
|
+
synthesisTarget: 'audio-file',
|
|
58
|
+
audioFileFormats: ['mp3', 'm4a', 'wav', 'ogg', 'opus'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
49
61
|
},
|
|
50
62
|
reload: { configPrefixes: ['channels.max'] },
|
|
51
63
|
commands: {
|
|
@@ -69,8 +81,12 @@ export const maxPlugin = {
|
|
|
69
81
|
directory: maxDirectoryAdapter,
|
|
70
82
|
outbound: maxOutboundAdapter,
|
|
71
83
|
setup: maxSetupAdapter,
|
|
84
|
+
// botToken, webhookSecret, httpProxy accept SecretRefs (also exported as
|
|
85
|
+
// dist/secret-contract-api.js for the gateway's pre-runtime resolution).
|
|
86
|
+
secrets: maxChannelSecrets,
|
|
72
87
|
status: maxStatusAdapter,
|
|
73
88
|
gateway: maxGatewayAdapter,
|
|
89
|
+
heartbeat: { sendTyping: sendMaxHeartbeatTyping, sendTypingGuarded: sendMaxHeartbeatTyping },
|
|
74
90
|
// Message actions (send, edit, delete)
|
|
75
91
|
actions: maxMessageActions,
|
|
76
92
|
};
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
import { buildCommandTextFromArgs, findCommandByNativeName, formatCommandArgMenuTitle, formatFastModeCurrentStatus, listChatCommands, normalizeCommandBody, parseCommandArgs, resolveCommandArgChoices, resolveCommandArgMenu, resolveCommandAuthorization, resolveEffectiveAgentRuntime, resolveFastModeState, resolveStoredModelOverride, } from 'openclaw/plugin-sdk/command-auth-native';
|
|
12
|
+
import { MAX_CALLBACK_PAYLOAD_BYTES } from './presentation.js';
|
|
13
|
+
import { getMaxRuntime } from './runtime.js';
|
|
14
|
+
/** Private envelope of a menu button: the command text of one choice. */
|
|
15
|
+
const COMMAND_MENU_PREFIX = 'mxcmd1:';
|
|
16
|
+
/** Session fields whose stored value is one of the command's choice values. */
|
|
17
|
+
const SESSION_CHOICE_FIELDS = {
|
|
18
|
+
think: 'thinkingLevel',
|
|
19
|
+
verbose: 'verboseLevel',
|
|
20
|
+
reasoning: 'reasoningLevel',
|
|
21
|
+
elevated: 'elevatedLevel',
|
|
22
|
+
trace: 'traceLevel',
|
|
23
|
+
usage: 'responseUsage',
|
|
24
|
+
activation: 'groupActivation',
|
|
25
|
+
};
|
|
26
|
+
export function encodeMaxCommandMenuPayload(commandText) {
|
|
27
|
+
const payload = `${COMMAND_MENU_PREFIX}${commandText}`;
|
|
28
|
+
return Buffer.byteLength(payload, 'utf8') <= MAX_CALLBACK_PAYLOAD_BYTES ? payload : null;
|
|
29
|
+
}
|
|
30
|
+
/** Command text of a menu button press; null for any other payload. */
|
|
31
|
+
export function decodeMaxCommandMenuPayload(payload) {
|
|
32
|
+
if (!payload?.startsWith(COMMAND_MENU_PREFIX))
|
|
33
|
+
return null;
|
|
34
|
+
const text = payload.slice(COMMAND_MENU_PREFIX.length).trim();
|
|
35
|
+
return text.startsWith('/') ? text : null;
|
|
36
|
+
}
|
|
37
|
+
/** A slash command whose core definition has an argument menu; null otherwise. */
|
|
38
|
+
function parseMenuCommand(text, botUsername) {
|
|
39
|
+
const normalized = normalizeCommandBody(text.trim(), botUsername ? { botUsername } : undefined);
|
|
40
|
+
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/u.exec(normalized);
|
|
41
|
+
if (!match)
|
|
42
|
+
return null;
|
|
43
|
+
const name = match[1].toLowerCase();
|
|
44
|
+
const command = findCommandByNativeName(name, 'max') ??
|
|
45
|
+
listChatCommands().find((entry) => entry.key === name || entry.textAliases.includes(`/${name}`));
|
|
46
|
+
if (!command?.args?.length || !command.argsMenu)
|
|
47
|
+
return null;
|
|
48
|
+
return { command, args: parseCommandArgs(command, match[2]?.trim() || undefined) };
|
|
49
|
+
}
|
|
50
|
+
/** The session's model (dynamic choices such as /think levels depend on it). */
|
|
51
|
+
function resolveMenuModelContext(params) {
|
|
52
|
+
const core = getMaxRuntime();
|
|
53
|
+
const { cfg, agentId, sessionKey } = params;
|
|
54
|
+
try {
|
|
55
|
+
const load = (key) => core.agent.session.getSessionEntry({ agentId, sessionKey: key });
|
|
56
|
+
const entry = load(sessionKey);
|
|
57
|
+
const defaults = core.modelConfig.resolveDefaultModelForAgent({ cfg, agentId });
|
|
58
|
+
// An automatic fallback override is temporary: menus follow the default.
|
|
59
|
+
const override = entry?.modelOverrideSource === 'auto'
|
|
60
|
+
? null
|
|
61
|
+
: resolveStoredModelOverride({
|
|
62
|
+
sessionEntry: entry,
|
|
63
|
+
loadSessionEntry: load,
|
|
64
|
+
sessionKey,
|
|
65
|
+
defaultProvider: defaults.provider,
|
|
66
|
+
});
|
|
67
|
+
const provider = override?.provider ?? defaults.provider;
|
|
68
|
+
const model = override?.model ?? defaults.model;
|
|
69
|
+
let agentRuntime;
|
|
70
|
+
try {
|
|
71
|
+
agentRuntime = resolveEffectiveAgentRuntime({
|
|
72
|
+
cfg,
|
|
73
|
+
provider,
|
|
74
|
+
modelId: model,
|
|
75
|
+
agentId,
|
|
76
|
+
sessionKey,
|
|
77
|
+
sessionEntry: entry,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
agentRuntime = undefined;
|
|
82
|
+
}
|
|
83
|
+
return { entry, provider, model, agentRuntime };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Every argument value of a pressed command is still among core's choices. */
|
|
90
|
+
function isPressStillOffered(command, args, cfg, context) {
|
|
91
|
+
const values = args?.values ?? {};
|
|
92
|
+
for (const arg of command.args ?? []) {
|
|
93
|
+
const value = values[arg.name];
|
|
94
|
+
if (value == null || !arg.choices)
|
|
95
|
+
continue;
|
|
96
|
+
const choices = resolveCommandArgChoices({
|
|
97
|
+
command,
|
|
98
|
+
arg,
|
|
99
|
+
cfg,
|
|
100
|
+
provider: context.provider,
|
|
101
|
+
model: context.model,
|
|
102
|
+
agentRuntime: context.agentRuntime,
|
|
103
|
+
});
|
|
104
|
+
if (choices.length > 0 && !choices.some((choice) => choice.value === String(value))) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
function resolveCurrentChoice(command, context, fastState) {
|
|
111
|
+
if (fastState) {
|
|
112
|
+
if (fastState.source !== 'session')
|
|
113
|
+
return 'default';
|
|
114
|
+
return fastState.mode === 'auto' ? 'auto' : fastState.mode ? 'on' : 'off';
|
|
115
|
+
}
|
|
116
|
+
const field = SESSION_CHOICE_FIELDS[command.key];
|
|
117
|
+
const stored = field ? context.entry?.[field] : undefined;
|
|
118
|
+
if (typeof stored === 'string' && stored)
|
|
119
|
+
return stored;
|
|
120
|
+
// Nothing stored for this session: the "default" choice is what applies.
|
|
121
|
+
return 'default';
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Decide what a slash command (typed, or pressed on a menu when `press`) does:
|
|
125
|
+
* open its argument menu, go to core unchanged, or — presses only — be refused
|
|
126
|
+
* for lack of command rights or as stale. `ctx` is the finalized inbound
|
|
127
|
+
* context of the message, so command rights are resolved exactly as core
|
|
128
|
+
* resolves them for the same text.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveMaxCommandMenu(params) {
|
|
131
|
+
const { press, cfg, agentId, sessionKey } = params;
|
|
132
|
+
const pass = press ? { kind: 'stale' } : { kind: 'dispatch' };
|
|
133
|
+
const parsed = parseMenuCommand(params.text, params.botUsername);
|
|
134
|
+
if (!parsed)
|
|
135
|
+
return pass;
|
|
136
|
+
const { command, args } = parsed;
|
|
137
|
+
const core = getMaxRuntime();
|
|
138
|
+
if (!core.channel.commands.shouldHandleTextCommands({ cfg, surface: 'max', commandSource: 'text' })) {
|
|
139
|
+
return pass;
|
|
140
|
+
}
|
|
141
|
+
// Core authorizes MAX text commands with CommandAuthorized unset (false).
|
|
142
|
+
const authorization = resolveCommandAuthorization({
|
|
143
|
+
ctx: params.ctx,
|
|
144
|
+
cfg,
|
|
145
|
+
commandAuthorized: params.ctx.CommandAuthorized === true,
|
|
146
|
+
});
|
|
147
|
+
if (!authorization.isAuthorizedSender) {
|
|
148
|
+
return press ? { kind: 'denied' } : { kind: 'dispatch' };
|
|
149
|
+
}
|
|
150
|
+
const context = resolveMenuModelContext({ cfg, agentId, sessionKey });
|
|
151
|
+
if (press && !isPressStillOffered(command, args, cfg, context))
|
|
152
|
+
return { kind: 'stale' };
|
|
153
|
+
const menu = resolveCommandArgMenu({
|
|
154
|
+
command,
|
|
155
|
+
args,
|
|
156
|
+
cfg,
|
|
157
|
+
provider: context.provider,
|
|
158
|
+
model: context.model,
|
|
159
|
+
agentRuntime: context.agentRuntime,
|
|
160
|
+
session: { agentId, sessionKey },
|
|
161
|
+
});
|
|
162
|
+
if (!menu)
|
|
163
|
+
return { kind: 'dispatch' };
|
|
164
|
+
const fastState = command.key === 'fast' && context.provider && context.model
|
|
165
|
+
? resolveFastModeState({
|
|
166
|
+
cfg,
|
|
167
|
+
provider: context.provider,
|
|
168
|
+
model: context.model,
|
|
169
|
+
agentId,
|
|
170
|
+
sessionEntry: context.entry?.fastMode !== undefined
|
|
171
|
+
? { fastMode: context.entry.fastMode }
|
|
172
|
+
: undefined,
|
|
173
|
+
})
|
|
174
|
+
: undefined;
|
|
175
|
+
const current = resolveCurrentChoice(command, context, fastState);
|
|
176
|
+
let title = formatCommandArgMenuTitle({ command, menu });
|
|
177
|
+
if (fastState) {
|
|
178
|
+
title = `${formatFastModeCurrentStatus(fastState)}\n${title}`;
|
|
179
|
+
}
|
|
180
|
+
else if (command.key === 'think' && current !== 'default') {
|
|
181
|
+
title = `Current thinking level: ${current}.\n${title}`;
|
|
182
|
+
}
|
|
183
|
+
const buttons = [];
|
|
184
|
+
for (const choice of menu.choices) {
|
|
185
|
+
const payload = encodeMaxCommandMenuPayload(buildCommandTextFromArgs(command, {
|
|
186
|
+
values: { ...args?.values, [menu.arg.name]: choice.value },
|
|
187
|
+
}));
|
|
188
|
+
if (!payload)
|
|
189
|
+
continue;
|
|
190
|
+
buttons.push({ text: choice.value === current ? `${choice.label} ✓` : choice.label, payload });
|
|
191
|
+
}
|
|
192
|
+
if (buttons.length === 0)
|
|
193
|
+
return { kind: 'dispatch' };
|
|
194
|
+
const rows = [];
|
|
195
|
+
for (let index = 0; index < buttons.length; index += 2) {
|
|
196
|
+
rows.push(buttons.slice(index, index + 2));
|
|
197
|
+
}
|
|
198
|
+
return { kind: 'menu', text: title, buttons: rows };
|
|
199
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MAX channel config Zod schema
|
|
3
3
|
*/
|
|
4
|
-
import { MarkdownConfigSchema, ToolPolicySchema } from 'openclaw/plugin-sdk/channel-config-schema';
|
|
4
|
+
import { ChannelPreviewStreamingConfigSchema, MarkdownConfigSchema, MentionPatternsPolicySchema, ToolPolicySchema, } from 'openclaw/plugin-sdk/channel-config-schema';
|
|
5
|
+
import { buildOptionalSecretInputSchema } from 'openclaw/plugin-sdk/secret-input';
|
|
5
6
|
import { z } from 'zod';
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Policy fields are built with this package's own zod: wrapping the SDK's enum
|
|
8
|
+
// instances in `.optional().default()` broke as soon as the plugin and the
|
|
9
|
+
// gateway resolved different zod copies (4.4 vs 4.6 → "expected nonoptional").
|
|
10
|
+
// Values mirror the SDK's DmPolicySchema/GroupPolicySchema.
|
|
11
|
+
//
|
|
12
|
+
// No field carries a schema default: the gateway writes JSON-schema defaults
|
|
13
|
+
// into the runtime config, also under accounts.<id>, and a default there would
|
|
14
|
+
// hide the channel-level value a named account inherits. The effective
|
|
15
|
+
// defaults (pairing, allowlist, …) are applied where the values are read.
|
|
10
16
|
export const DmPolicySchema = z.enum(['pairing', 'allowlist', 'open', 'disabled']);
|
|
11
17
|
export const GroupPolicySchema = z.enum(['open', 'disabled', 'allowlist']);
|
|
12
18
|
// These schemas were removed from the public plugin-sdk surface in OpenClaw 2026.3.x.
|
|
@@ -42,45 +48,133 @@ export const MaxGroupSchema = z
|
|
|
42
48
|
.object({
|
|
43
49
|
requireMention: z.boolean().optional(),
|
|
44
50
|
tools: ToolPolicySchema.optional(),
|
|
51
|
+
/** Skills a group turn may load (core skillFilter; [] = none). */
|
|
45
52
|
skills: z.array(z.string()).optional(),
|
|
53
|
+
/** false: the group is ignored under any groupPolicy. */
|
|
46
54
|
enabled: z.boolean().optional(),
|
|
47
55
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
56
|
+
/** Extra system prompt of group turns (core GroupSystemPrompt). */
|
|
48
57
|
systemPrompt: z.string().optional(),
|
|
58
|
+
/** Skip transcribing a captionless voice message to find a mention pattern. */
|
|
59
|
+
disableAudioPreflight: z.boolean().optional(),
|
|
49
60
|
})
|
|
50
61
|
.strict();
|
|
51
62
|
/**
|
|
52
|
-
* MAX account config (base schema for both top-level and accounts.*)
|
|
63
|
+
* MAX account config (base schema for both top-level and accounts.*).
|
|
64
|
+
* accounts.<id> inherits every channel-level value it does not set, except
|
|
65
|
+
* the per-bot keys (token, name, webhook; MAX_ACCOUNT_OWN_KEYS in accounts.ts).
|
|
53
66
|
*/
|
|
54
67
|
export const MaxAccountSchemaBase = z
|
|
55
68
|
.object({
|
|
56
69
|
name: z.string().optional(),
|
|
57
70
|
enabled: z.boolean().optional(),
|
|
71
|
+
/** No effect in MAX: neither the plugin nor core reads it for this channel. */
|
|
58
72
|
markdown: MarkdownConfigSchema.optional(),
|
|
59
|
-
|
|
73
|
+
/** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
|
|
74
|
+
botToken: buildOptionalSecretInputSchema(),
|
|
60
75
|
tokenFile: z.string().optional(),
|
|
61
|
-
|
|
76
|
+
/** Default "pairing" (applied where read); named accounts inherit the channel value. */
|
|
77
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
62
78
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
63
79
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
64
|
-
|
|
80
|
+
/** Default "allowlist" (applied where read); named accounts inherit the channel value. */
|
|
81
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
65
82
|
groups: z.record(z.string(), MaxGroupSchema.optional()).optional(),
|
|
83
|
+
/**
|
|
84
|
+
* Where core mention patterns (messages.groupChat / agent groupChat
|
|
85
|
+
* mentionPatterns) apply in MAX groups: mode allow|deny, allowIn, denyIn.
|
|
86
|
+
*/
|
|
87
|
+
mentionPatterns: MentionPatternsPolicySchema.optional(),
|
|
66
88
|
/** Update transport; default: "webhook" when webhookUrl is set, otherwise "polling" */
|
|
67
89
|
transport: z.enum(['polling', 'webhook']).optional(),
|
|
68
90
|
webhookUrl: z.string().optional(),
|
|
69
91
|
/** MAX secret: 5–256 of [A-Za-z0-9_-]; checked at start */
|
|
70
|
-
webhookSecret:
|
|
92
|
+
webhookSecret: buildOptionalSecretInputSchema(),
|
|
71
93
|
/** File holding the webhook secret (regular file, not a symlink) */
|
|
72
94
|
webhookSecretFile: z.string().optional(),
|
|
73
95
|
webhookPath: z.string().optional(),
|
|
96
|
+
/**
|
|
97
|
+
* Webhook queue: "durable" (default) journals every accepted update before
|
|
98
|
+
* MAX gets its 200, so a restart or crash does not lose it; "memory" keeps
|
|
99
|
+
* the 0.7 in-memory queue. maxPending (default 5000) bounds accepted but
|
|
100
|
+
* unhandled updates; when full, overflow "reject" (default) answers 503
|
|
101
|
+
* so MAX redelivers later, "drop" acknowledges and drops.
|
|
102
|
+
*/
|
|
103
|
+
webhookQueue: z
|
|
104
|
+
.object({
|
|
105
|
+
mode: z.enum(['durable', 'memory']).optional(),
|
|
106
|
+
maxPending: z.number().int().positive().optional(),
|
|
107
|
+
overflow: z.enum(['reject', 'drop']).optional(),
|
|
108
|
+
})
|
|
109
|
+
.strict()
|
|
110
|
+
.optional(),
|
|
111
|
+
/**
|
|
112
|
+
* Messages, edits, button presses and bot_started older than this many
|
|
113
|
+
* minutes (by MAX event time) are skipped instead of answered: after a
|
|
114
|
+
* long outage the bot does not reply to hours-old messages. Default 60;
|
|
115
|
+
* 0 answers any age.
|
|
116
|
+
*/
|
|
117
|
+
maxEventAgeMinutes: z.number().int().min(0).optional(),
|
|
118
|
+
/**
|
|
119
|
+
* Debug logs of inbound messages carry a text preview (50 characters).
|
|
120
|
+
* Off by default: logs hold message type, length and ids, never text.
|
|
121
|
+
*/
|
|
122
|
+
logMessagePreview: z.boolean().optional(),
|
|
123
|
+
/**
|
|
124
|
+
* Read by core, not the plugin: the embedded agent runtime keeps this many
|
|
125
|
+
* recent turns of a group session (historyLimit) or DM session
|
|
126
|
+
* (dmHistoryLimit, dms.<peer>.historyLimit) in the prompt.
|
|
127
|
+
*/
|
|
74
128
|
historyLimit: z.number().int().min(0).optional(),
|
|
75
129
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
76
130
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
131
|
+
/** Characters per outgoing message (core resolution), at most MAX's 4000. */
|
|
77
132
|
textChunkLimit: z.number().int().positive().optional(),
|
|
133
|
+
/** No effect in MAX: use streamMode / streaming.mode "block". */
|
|
78
134
|
blockStreaming: z.boolean().optional(),
|
|
79
135
|
streamMode: z.enum(['off', 'partial', 'block']).optional(),
|
|
136
|
+
/**
|
|
137
|
+
* Core streaming settings; streaming.mode wins over streamMode. "progress"
|
|
138
|
+
* shows one turn-status message (streaming.progress: label, toolProgress,
|
|
139
|
+
* maxLines, …) that is deleted once the answer landed. Default off.
|
|
140
|
+
*/
|
|
141
|
+
streaming: ChannelPreviewStreamingConfigSchema.optional(),
|
|
142
|
+
/** No effect in MAX: core reads streaming.block.coalesce instead. */
|
|
80
143
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
144
|
+
/** Prefix of agent replies, applied by core ("auto" = agent name). */
|
|
81
145
|
responsePrefix: z.string().optional(),
|
|
82
146
|
mediaMaxMb: z.number().positive().optional(),
|
|
147
|
+
/** Most media attachments downloaded per inbound message (default 12); the rest is noted. */
|
|
148
|
+
mediaMaxCount: z.number().int().positive().optional(),
|
|
83
149
|
markSeen: z.boolean().optional(),
|
|
150
|
+
/**
|
|
151
|
+
* Chats message-tool actions may act in: "admitted" (default) — the current
|
|
152
|
+
* chat, owner requests and chats admitted by the DM/group policy;
|
|
153
|
+
* "current" — non-owner turns only in their own chat; "off" — no check.
|
|
154
|
+
*/
|
|
155
|
+
actionScope: z.enum(['admitted', 'current', 'off']).optional(),
|
|
156
|
+
/**
|
|
157
|
+
* Send defaults; an explicit value of the call (core `silent`,
|
|
158
|
+
* channelData.max) wins. notify=false sends without push notifications
|
|
159
|
+
* (channels always notify).
|
|
160
|
+
*/
|
|
161
|
+
notify: z.boolean().optional(),
|
|
162
|
+
disableLinkPreview: z.boolean().optional(),
|
|
163
|
+
/**
|
|
164
|
+
* MAX Bot API base URL (default https://platform-api2.max.ru), e.g. a test
|
|
165
|
+
* stand; https only, http just for loopback hosts. Checked at start.
|
|
166
|
+
*/
|
|
167
|
+
apiBaseUrl: z.string().optional(),
|
|
168
|
+
/**
|
|
169
|
+
* HTTP(S) proxy for all MAX traffic of the account (API, uploads, media
|
|
170
|
+
* downloads), e.g. http://user:pass@host:3128; "" turns an inherited one
|
|
171
|
+
* off. Sensitive: credentials are never logged. Accepts a SecretRef.
|
|
172
|
+
*/
|
|
173
|
+
httpProxy: buildOptionalSecretInputSchema(),
|
|
174
|
+
/**
|
|
175
|
+
* Message-tool actions: false turns one off (hidden from the tool and
|
|
176
|
+
* refused). Other values, including the legacy policy strings, leave it on.
|
|
177
|
+
*/
|
|
84
178
|
actions: z
|
|
85
179
|
.record(z.string(), z.union([z.boolean(), z.enum(['pairing', 'allowlist', 'open'])]).optional())
|
|
86
180
|
.optional(),
|
|
@@ -121,6 +215,8 @@ const MaxBotCommandSchema = z
|
|
|
121
215
|
export const MaxConfigSchema = MaxAccountSchemaBase.extend({
|
|
122
216
|
accounts: z.record(z.string(), MaxAccountSchema.optional()).optional(),
|
|
123
217
|
commands: z.array(MaxBotCommandSchema).max(32).optional(),
|
|
218
|
+
/** Accepted as in the manifest; the channel-level options are always the "default" account. */
|
|
219
|
+
defaultAccount: z.string().optional(),
|
|
124
220
|
}).superRefine((value, ctx) => {
|
|
125
221
|
requireOpenAllowFrom({
|
|
126
222
|
policy: value.dmPolicy,
|