@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
@@ -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 type { ResolvedMaxAccount } from './accounts.js';
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: Boolean(account.token?.trim()),
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: 'MAX bot token not configured',
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
@@ -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
- /** Direct delivery with 4000-character markdown chunks. */
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
- chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, limit),
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 ({ to, text, payload: rawPayload, mediaUrl, accountId, replyToId }) => {
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 > MAX_TEXT_LIMIT
92
- ? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, MAX_TEXT_LIMIT)
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 ({ to, text, mediaUrl, accountId, replyToId }) => {
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.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
+ }