@aspalagin/openclaw-max 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
@@ -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
- /** Direct delivery with 4000-character markdown chunks. */
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
- chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, limit),
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 ({ to, text, payload: rawPayload, mediaUrl, accountId, replyToId }) => {
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 chunks = effectiveText.length > MAX_TEXT_LIMIT
71
- ? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, MAX_TEXT_LIMIT)
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 ({ to, text, mediaUrl, accountId, replyToId }) => {
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',
@@ -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
- // Defaulted policy fields are built with this package's own zod: wrapping the
7
- // SDK's enum instances in `.optional().default()` breaks as soon as the plugin
8
- // and the gateway resolve different zod copies (4.4 vs 4.6 → "expected
9
- // nonoptional"). Values mirror the SDK's DmPolicySchema/GroupPolicySchema.
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
- botToken: z.string().optional(),
73
+ /** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
74
+ botToken: buildOptionalSecretInputSchema(),
60
75
  tokenFile: z.string().optional(),
61
- dmPolicy: DmPolicySchema.optional().default('pairing'),
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
- groupPolicy: GroupPolicySchema.optional().default('allowlist'),
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: z.string().optional(),
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,