@aspalagin/openclaw-max 0.7.2

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/SKILL.md +108 -0
  4. package/dist/index.js +17 -0
  5. package/dist/setup-entry.js +8 -0
  6. package/dist/src/accounts.js +148 -0
  7. package/dist/src/actions.js +421 -0
  8. package/dist/src/api.js +553 -0
  9. package/dist/src/callbacks.js +111 -0
  10. package/dist/src/channel-agent-prompt.js +38 -0
  11. package/dist/src/channel-config.js +103 -0
  12. package/dist/src/channel-directory.js +84 -0
  13. package/dist/src/channel-lifecycle.js +219 -0
  14. package/dist/src/channel-outbound.js +133 -0
  15. package/dist/src/channel-policy.js +130 -0
  16. package/dist/src/channel.js +76 -0
  17. package/dist/src/config-schema.js +133 -0
  18. package/dist/src/deliver.js +126 -0
  19. package/dist/src/dispatch.js +186 -0
  20. package/dist/src/format.js +31 -0
  21. package/dist/src/inbound-attachments.js +125 -0
  22. package/dist/src/inbound.js +362 -0
  23. package/dist/src/media-temp.js +92 -0
  24. package/dist/src/model-buttons.js +96 -0
  25. package/dist/src/monitor-types.js +27 -0
  26. package/dist/src/monitor.js +40 -0
  27. package/dist/src/onboarding.js +180 -0
  28. package/dist/src/polling.js +98 -0
  29. package/dist/src/presentation.js +374 -0
  30. package/dist/src/runtime.js +36 -0
  31. package/dist/src/russian-trusted-ca.js +84 -0
  32. package/dist/src/send.js +612 -0
  33. package/dist/src/state.js +111 -0
  34. package/dist/src/sticker-cache.js +26 -0
  35. package/dist/src/stream-draft.js +102 -0
  36. package/dist/src/types.js +5 -0
  37. package/dist/src/webhook-queue.js +215 -0
  38. package/dist/src/webhook-runner.js +233 -0
  39. package/dist/src/webhook.js +196 -0
  40. package/openclaw.plugin.json +263 -0
  41. package/package.json +100 -0
  42. package/scripts/check-cycles.mjs +38 -0
  43. package/scripts/test-api.mjs +169 -0
  44. package/scripts/test-send.mjs +162 -0
  45. package/scripts/update-schema.mjs +48 -0
  46. package/src/accounts.ts +193 -0
  47. package/src/actions.ts +501 -0
  48. package/src/api.ts +758 -0
  49. package/src/callbacks.ts +140 -0
  50. package/src/channel-agent-prompt.ts +47 -0
  51. package/src/channel-config.ts +138 -0
  52. package/src/channel-directory.ts +90 -0
  53. package/src/channel-lifecycle.ts +256 -0
  54. package/src/channel-outbound.ts +159 -0
  55. package/src/channel-policy.ts +185 -0
  56. package/src/channel.ts +99 -0
  57. package/src/config-schema.ts +154 -0
  58. package/src/deliver.ts +168 -0
  59. package/src/dispatch.ts +224 -0
  60. package/src/format.ts +33 -0
  61. package/src/inbound-attachments.ts +152 -0
  62. package/src/inbound.ts +412 -0
  63. package/src/media-temp.ts +103 -0
  64. package/src/model-buttons.ts +150 -0
  65. package/src/monitor-types.ts +61 -0
  66. package/src/monitor.ts +49 -0
  67. package/src/onboarding.ts +215 -0
  68. package/src/polling.ts +125 -0
  69. package/src/presentation.ts +452 -0
  70. package/src/runtime.ts +56 -0
  71. package/src/russian-trusted-ca.ts +86 -0
  72. package/src/send.ts +774 -0
  73. package/src/state.ts +149 -0
  74. package/src/sticker-cache.ts +28 -0
  75. package/src/stream-draft.ts +125 -0
  76. package/src/types.ts +263 -0
  77. package/src/webhook-queue.ts +248 -0
  78. package/src/webhook-runner.ts +296 -0
  79. package/src/webhook.ts +268 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Outbound adapter of the MAX channel: sendText / sendPayload / sendMedia,
3
+ * presentation rendering and delivery pins for core's outbound path.
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+
8
+ import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
9
+ import {
10
+ materializeMaxPresentation,
11
+ MAX_PRESENTATION_CAPABILITIES,
12
+ MAX_TEXT_LIMIT,
13
+ renderMaxPresentation,
14
+ } from './presentation.js';
15
+ import { getMaxRuntime, loadMaxConfig } from './runtime.js';
16
+ import {
17
+ pinMaxMessage,
18
+ readMaxChannelButtons,
19
+ sendMaxMediaGroup,
20
+ sendMaxMediaMessage,
21
+ sendMaxMessage,
22
+ } from './send.js';
23
+
24
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
25
+
26
+ /** Direct delivery with 4000-character markdown chunks. */
27
+ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
28
+ deliveryMode: 'direct',
29
+ chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, limit),
30
+ chunkerMode: 'markdown',
31
+ textChunkLimit: 4000,
32
+
33
+ // Presentation (docs/plugins/message-presentation.md): core adapts to these
34
+ // capabilities, renders through renderPresentation and pins through
35
+ // pinDeliveredMessage after the first delivered message.
36
+ presentationCapabilities: MAX_PRESENTATION_CAPABILITIES,
37
+ deliveryCapabilities: { pin: true },
38
+ renderPresentation: ({ payload, presentation }) => renderMaxPresentation(payload, presentation),
39
+ pinDeliveredMessage: async ({ cfg, target, messageId, pin, assertDirectAdapterHandoff }) => {
40
+ const account = resolveMaxAccount({ cfg, accountId: target.accountId });
41
+ if (!account.token) throw new Error('MAX bot token not configured');
42
+ assertDirectAdapterHandoff?.();
43
+ // Dialogs cannot pin: pinMaxMessage resolves { pinned: false } without an
44
+ // API call or error, so core logs no failed pin for a DM delivery.
45
+ await pinMaxMessage(target.to, messageId, {
46
+ token: account.token,
47
+ pinNotify: pin.notify === true,
48
+ beforeRequest: assertDirectAdapterHandoff,
49
+ });
50
+ },
51
+
52
+ sendPayload: async ({ to, text, payload: rawPayload, mediaUrl, accountId, replyToId }) => {
53
+ const cfg = await loadMaxConfig();
54
+ const account = resolveMaxAccount({ cfg, accountId });
55
+ if (!account.token) throw new Error('MAX bot token not configured');
56
+
57
+ // Core renders presentation before sendPayload; a payload that still
58
+ // carries one came through a path that did not, so apply the same policy.
59
+ const payload = rawPayload.presentation
60
+ ? await materializeMaxPresentation(rawPayload)
61
+ : rawPayload;
62
+ const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
63
+ const buttons = readMaxChannelButtons(payload.channelData);
64
+ const mediaUrls = mediaUrl
65
+ ? [mediaUrl]
66
+ : payload.mediaUrls?.length
67
+ ? payload.mediaUrls
68
+ : payload.mediaUrl
69
+ ? [payload.mediaUrl]
70
+ : [];
71
+
72
+ if (mediaUrls.length) {
73
+ // Albums: up to 12 images/videos per message, caption on the first,
74
+ // buttons on the last; report the first message (delivery.pin).
75
+ const result = await sendMaxMediaGroup(to, effectiveText, mediaUrls, {
76
+ token: account.token,
77
+ replyToMessageId: replyToId ?? undefined,
78
+ format: 'markdown',
79
+ buttons,
80
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
81
+ });
82
+ return {
83
+ channel: 'max',
84
+ messageId: result.messageIds[0] ?? '',
85
+ };
86
+ }
87
+
88
+ // MAX caps a message at 4000 characters: split, keyboard on the last
89
+ // chunk, report the first one (delivery.pin pins the first chunk).
90
+ const chunks =
91
+ effectiveText.length > MAX_TEXT_LIMIT
92
+ ? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, MAX_TEXT_LIMIT)
93
+ : [effectiveText];
94
+ let firstMessageId = '';
95
+ for (let index = 0; index < chunks.length; index += 1) {
96
+ const result = await sendMaxMessage(to, chunks[index], {
97
+ token: account.token,
98
+ replyToMessageId: index === 0 ? (replyToId ?? undefined) : undefined,
99
+ format: 'markdown',
100
+ buttons: index === chunks.length - 1 ? buttons : undefined,
101
+ });
102
+ if (index === 0) firstMessageId = result.messageId;
103
+ }
104
+
105
+ return {
106
+ channel: 'max',
107
+ messageId: firstMessageId,
108
+ };
109
+ },
110
+
111
+ sendText: async ({ to, text, accountId, replyToId }) => {
112
+ const cfg = await loadMaxConfig();
113
+ const account = resolveMaxAccount({ cfg, accountId });
114
+ if (!account.token) throw new Error('MAX bot token not configured');
115
+
116
+ const result = await sendMaxMessage(to, text, {
117
+ token: account.token,
118
+ replyToMessageId: replyToId ?? undefined,
119
+ format: 'markdown',
120
+ });
121
+
122
+ return {
123
+ channel: 'max',
124
+ messageId: result.messageId,
125
+ };
126
+ },
127
+
128
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
129
+ const cfg = await loadMaxConfig();
130
+ const account = resolveMaxAccount({ cfg, accountId });
131
+ if (!account.token) throw new Error('MAX bot token not configured');
132
+
133
+ if (!mediaUrl) {
134
+ // No media, send as text
135
+ const result = await sendMaxMessage(to, text, {
136
+ token: account.token,
137
+ replyToMessageId: replyToId ?? undefined,
138
+ format: 'markdown',
139
+ });
140
+ return {
141
+ channel: 'max',
142
+ messageId: result.messageId,
143
+ };
144
+ }
145
+
146
+ // Upload and send media
147
+ const result = await sendMaxMediaMessage(to, text, mediaUrl, {
148
+ token: account.token,
149
+ replyToMessageId: replyToId ?? undefined,
150
+ format: 'markdown',
151
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
152
+ });
153
+
154
+ return {
155
+ channel: 'max',
156
+ messageId: result.messageId,
157
+ };
158
+ },
159
+ };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Access policy of the MAX channel: DM pairing/allowlist, group
3
+ * requireMention and tool policy, security warnings, pairing approval.
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+ import type { GroupToolPolicyConfig } from 'openclaw/plugin-sdk/channel-policy';
8
+ import { resolveToolsBySender } from 'openclaw/plugin-sdk/channel-policy';
9
+ import { PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk/channel-status';
10
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
11
+ import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from 'openclaw/plugin-sdk/core';
12
+
13
+ import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
14
+ import { sendMaxMessage } from './send.js';
15
+
16
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
17
+
18
+ // ── MAX group policy helpers ──
19
+ // These mirror resolveChannelGroupRequireMention/resolveChannelGroupToolsPolicy
20
+ // (internal SDK functions not exported) but for the "max" channel.
21
+
22
+ function resolveMaxGroupConfig(
23
+ cfg: OpenClawConfig,
24
+ groupId?: string | null,
25
+ accountId?: string | null,
26
+ ) {
27
+ const maxSection = (cfg.channels as Record<string, unknown>)?.max as
28
+ Record<string, unknown> | undefined;
29
+ if (!maxSection) return { groupConfig: undefined, defaultConfig: undefined };
30
+
31
+ // Resolve groups map: account-level takes priority over channel-level
32
+ let groups: Record<string, unknown> | undefined;
33
+ if (accountId && accountId !== DEFAULT_ACCOUNT_ID) {
34
+ const accounts = maxSection.accounts as Record<string, Record<string, unknown>> | undefined;
35
+ groups = accounts?.[accountId]?.groups as Record<string, unknown> | undefined;
36
+ }
37
+ if (!groups) {
38
+ groups = maxSection.groups as Record<string, unknown> | undefined;
39
+ }
40
+
41
+ const normalizedId = groupId?.trim();
42
+ const groupConfig =
43
+ normalizedId && groups
44
+ ? (groups[normalizedId] as Record<string, unknown> | undefined)
45
+ : undefined;
46
+ const defaultConfig = groups?.['*'] as Record<string, unknown> | undefined;
47
+
48
+ return { groupConfig, defaultConfig };
49
+ }
50
+
51
+ function resolveMaxGroupRequireMention(params: {
52
+ cfg: OpenClawConfig;
53
+ groupId?: string | null;
54
+ accountId?: string | null;
55
+ }): boolean {
56
+ const { groupConfig, defaultConfig } = resolveMaxGroupConfig(
57
+ params.cfg,
58
+ params.groupId,
59
+ params.accountId,
60
+ );
61
+ const configMention =
62
+ typeof groupConfig?.requireMention === 'boolean'
63
+ ? groupConfig.requireMention
64
+ : typeof defaultConfig?.requireMention === 'boolean'
65
+ ? defaultConfig.requireMention
66
+ : undefined;
67
+ if (typeof configMention === 'boolean') return configMention;
68
+ return true; // default: require mention
69
+ }
70
+
71
+ function resolveMaxGroupToolPolicy(params: {
72
+ cfg: OpenClawConfig;
73
+ groupId?: string | null;
74
+ accountId?: string | null;
75
+ senderId?: string | null;
76
+ senderName?: string | null;
77
+ senderUsername?: string | null;
78
+ senderE164?: string | null;
79
+ }): GroupToolPolicyConfig | undefined {
80
+ const { groupConfig, defaultConfig } = resolveMaxGroupConfig(
81
+ params.cfg,
82
+ params.groupId,
83
+ params.accountId,
84
+ );
85
+
86
+ // Group-level sender-specific policy
87
+ const groupSenderPolicy = resolveToolsBySender({
88
+ toolsBySender: groupConfig?.toolsBySender as Record<string, GroupToolPolicyConfig> | undefined,
89
+ senderId: params.senderId,
90
+ senderName: params.senderName,
91
+ senderUsername: params.senderUsername,
92
+ senderE164: params.senderE164,
93
+ });
94
+ if (groupSenderPolicy) return groupSenderPolicy;
95
+ if (groupConfig?.tools) return groupConfig.tools as GroupToolPolicyConfig;
96
+
97
+ // Default config fallback
98
+ const defaultSenderPolicy = resolveToolsBySender({
99
+ toolsBySender: defaultConfig?.toolsBySender as
100
+ Record<string, GroupToolPolicyConfig> | undefined,
101
+ senderId: params.senderId,
102
+ senderName: params.senderName,
103
+ senderUsername: params.senderUsername,
104
+ senderE164: params.senderE164,
105
+ });
106
+ if (defaultSenderPolicy) return defaultSenderPolicy;
107
+ if (defaultConfig?.tools) return defaultConfig.tools as GroupToolPolicyConfig;
108
+
109
+ return undefined;
110
+ }
111
+
112
+ /** DM policy (default pairing) and warnings for open group policy. */
113
+ export const maxSecurityAdapter: NonNullable<MaxChannelPlugin['security']> = {
114
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
115
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
116
+ const maxSection = (cfg.channels as Record<string, unknown>)?.max as
117
+ Record<string, unknown> | undefined;
118
+ const useAccountPath = Boolean(
119
+ (maxSection?.accounts as Record<string, unknown>)?.[resolvedAccountId],
120
+ );
121
+ const basePath = useAccountPath
122
+ ? `channels.max.accounts.${resolvedAccountId}.`
123
+ : 'channels.max.';
124
+ return {
125
+ policy: account.config.dmPolicy ?? 'pairing',
126
+ allowFrom: account.config.allowFrom ?? [],
127
+ policyPath: `${basePath}dmPolicy`,
128
+ allowFromPath: basePath,
129
+ approveHint: formatPairingApproveHint('max'),
130
+ normalizeEntry: (raw: string) => raw.replace(/^max:/i, ''),
131
+ };
132
+ },
133
+ collectWarnings: ({ account, cfg }) => {
134
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
135
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
136
+ if (groupPolicy !== 'open') {
137
+ return [];
138
+ }
139
+ const groupAllowlistConfigured =
140
+ account.config.groups && Object.keys(account.config.groups).length > 0;
141
+ if (groupAllowlistConfigured) {
142
+ return [
143
+ `- 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.`,
144
+ ];
145
+ }
146
+ return [
147
+ `- MAX groups: groupPolicy="open" with no channels.max.groups allowlist; any group can add + ping (mention-gated). Set channels.max.groupPolicy="allowlist" + channels.max.groupAllowFrom or configure channels.max.groups.`,
148
+ ];
149
+ },
150
+ };
151
+
152
+ /** Group requireMention (default true) and tool policy from channels.max.groups. */
153
+ export const maxGroupsAdapter: NonNullable<MaxChannelPlugin['groups']> = {
154
+ resolveRequireMention: ({ cfg, groupId, accountId }) =>
155
+ resolveMaxGroupRequireMention({ cfg, groupId, accountId }),
156
+ resolveToolPolicy: ({
157
+ cfg,
158
+ groupId,
159
+ accountId,
160
+ senderId,
161
+ senderName,
162
+ senderUsername,
163
+ senderE164,
164
+ }) =>
165
+ resolveMaxGroupToolPolicy({
166
+ cfg,
167
+ groupId,
168
+ accountId,
169
+ senderId,
170
+ senderName,
171
+ senderUsername,
172
+ senderE164,
173
+ }),
174
+ };
175
+
176
+ /** Pairing ids are MAX user ids; approval is announced to the user in MAX. */
177
+ export const maxPairingAdapter: NonNullable<MaxChannelPlugin['pairing']> = {
178
+ idLabel: 'maxUserId',
179
+ normalizeAllowEntry: (entry) => entry.replace(/^max:/i, ''),
180
+ notifyApproval: async ({ cfg, id }) => {
181
+ const account = resolveMaxAccount({ cfg });
182
+ if (!account.token) throw new Error('MAX bot token not configured');
183
+ await sendMaxMessage(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
184
+ },
185
+ };
package/src/channel.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * MAX channel plugin for OpenClaw.
3
+ *
4
+ * Implements the ChannelPlugin interface to integrate MAX messenger. The
5
+ * adapters live next to this file by role: channel-config (accounts, setup),
6
+ * channel-policy (DM/group access, pairing), channel-directory (targets,
7
+ * directory), channel-outbound (delivery), channel-lifecycle (start/stop,
8
+ * status, health), channel-agent-prompt; message tool actions in actions.ts.
9
+ */
10
+
11
+ import type { ChannelMeta } from 'openclaw/plugin-sdk/channel-contract';
12
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
13
+ import { buildChannelConfigSchema } from 'openclaw/plugin-sdk/core';
14
+
15
+ import type { ResolvedMaxAccount } from './accounts.js';
16
+ import { maxMessageActions } from './actions.js';
17
+ import { maxAgentPromptAdapter } from './channel-agent-prompt.js';
18
+ import { maxConfigAdapter, maxSetupAdapter } from './channel-config.js';
19
+ import { maxDirectoryAdapter, maxMessagingAdapter } from './channel-directory.js';
20
+ import { maxGatewayAdapter, maxStatusAdapter } from './channel-lifecycle.js';
21
+ import { maxOutboundAdapter } from './channel-outbound.js';
22
+ import { maxGroupsAdapter, maxPairingAdapter, maxSecurityAdapter } from './channel-policy.js';
23
+ import { MaxConfigSchema } from './config-schema.js';
24
+ import {
25
+ buildMaxModelBrowseChannelData,
26
+ buildMaxModelsAddProviderChannelData,
27
+ buildMaxModelsListChannelData,
28
+ buildMaxModelsMenuChannelData,
29
+ buildMaxModelsProviderChannelData,
30
+ } from './model-buttons.js';
31
+ import { maxSetupWizard } from './onboarding.js';
32
+
33
+ // ── Meta ──
34
+
35
+ const maxMeta: ChannelMeta = {
36
+ id: 'max',
37
+ label: 'MAX',
38
+ selectionLabel: 'MAX Messenger',
39
+ docsPath: '/channels/max',
40
+ blurb: 'MAX messenger bot via platform-api2.max.ru. Supports DMs, groups, inline keyboards.',
41
+ order: 50,
42
+ aliases: ['max-messenger'],
43
+ };
44
+
45
+ // ── Channel Plugin ──
46
+
47
+ export const maxPlugin: ChannelPlugin<ResolvedMaxAccount> = {
48
+ id: 'max',
49
+ meta: maxMeta,
50
+ setupWizard: maxSetupWizard,
51
+ configSchema: buildChannelConfigSchema(
52
+ MaxConfigSchema as unknown as Parameters<typeof buildChannelConfigSchema>[0],
53
+ ),
54
+
55
+ capabilities: {
56
+ chatTypes: ['direct', 'group', 'channel'],
57
+ reactions: false,
58
+ threads: false,
59
+ media: true,
60
+ nativeCommands: true,
61
+ blockStreaming: false,
62
+ edit: true,
63
+ // DELETE /messages backs the "delete" action; replies use link.type=reply.
64
+ unsend: true,
65
+ reply: true,
66
+ polls: false,
67
+ },
68
+
69
+ reload: { configPrefixes: ['channels.max'] },
70
+
71
+ commands: {
72
+ nativeCommandsAutoEnabled: true,
73
+ nativeSkillsAutoEnabled: true,
74
+ buildModelsMenuChannelData: buildMaxModelsMenuChannelData,
75
+ buildModelsProviderChannelData: buildMaxModelsProviderChannelData,
76
+ buildModelsAddProviderChannelData: buildMaxModelsAddProviderChannelData,
77
+ buildModelsListChannelData: buildMaxModelsListChannelData,
78
+ buildModelBrowseChannelData: buildMaxModelBrowseChannelData,
79
+ },
80
+
81
+ agentPrompt: maxAgentPromptAdapter,
82
+ config: maxConfigAdapter,
83
+ security: maxSecurityAdapter,
84
+ groups: maxGroupsAdapter,
85
+ pairing: maxPairingAdapter,
86
+
87
+ threading: {
88
+ resolveReplyToMode: () => 'first',
89
+ },
90
+ messaging: maxMessagingAdapter,
91
+ directory: maxDirectoryAdapter,
92
+ outbound: maxOutboundAdapter,
93
+ setup: maxSetupAdapter,
94
+ status: maxStatusAdapter,
95
+ gateway: maxGatewayAdapter,
96
+
97
+ // Message actions (send, edit, delete)
98
+ actions: maxMessageActions,
99
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MAX channel config Zod schema
3
+ */
4
+
5
+ import { MarkdownConfigSchema, ToolPolicySchema } from 'openclaw/plugin-sdk/channel-config-schema';
6
+ import { z } from 'zod';
7
+
8
+ // Defaulted policy fields are built with this package's own zod: wrapping the
9
+ // SDK's enum instances in `.optional().default()` breaks as soon as the plugin
10
+ // and the gateway resolve different zod copies (4.4 vs 4.6 → "expected
11
+ // nonoptional"). Values mirror the SDK's DmPolicySchema/GroupPolicySchema.
12
+ export const DmPolicySchema = z.enum(['pairing', 'allowlist', 'open', 'disabled']);
13
+ export const GroupPolicySchema = z.enum(['open', 'disabled', 'allowlist']);
14
+
15
+ // These schemas were removed from the public plugin-sdk surface in OpenClaw 2026.3.x.
16
+ // Inlined here to stay compatible with both old and new runtimes.
17
+ const BlockStreamingCoalesceSchema = z
18
+ .object({
19
+ minChars: z.number().int().optional(),
20
+ maxChars: z.number().int().optional(),
21
+ idleMs: z.number().int().optional(),
22
+ })
23
+ .strict();
24
+
25
+ const DmConfigSchema = z
26
+ .object({
27
+ historyLimit: z.number().int().min(0).optional(),
28
+ })
29
+ .strict();
30
+
31
+ function requireOpenAllowFrom(params: {
32
+ policy: string | undefined;
33
+ allowFrom: unknown[] | undefined;
34
+ ctx: z.RefinementCtx;
35
+ path: (string | number)[];
36
+ message: string;
37
+ }): void {
38
+ if (params.policy !== 'open') return;
39
+ const normalized = (params.allowFrom ?? []).map(String);
40
+ if (normalized.includes('*')) return;
41
+ params.ctx.addIssue({
42
+ code: z.ZodIssueCode.custom,
43
+ path: params.path,
44
+ message: params.message,
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Per-group config for MAX chats
50
+ */
51
+ export const MaxGroupSchema = z
52
+ .object({
53
+ requireMention: z.boolean().optional(),
54
+ tools: ToolPolicySchema.optional(),
55
+ skills: z.array(z.string()).optional(),
56
+ enabled: z.boolean().optional(),
57
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
58
+ systemPrompt: z.string().optional(),
59
+ })
60
+ .strict();
61
+
62
+ /**
63
+ * MAX account config (base schema for both top-level and accounts.*)
64
+ */
65
+ export const MaxAccountSchemaBase = z
66
+ .object({
67
+ name: z.string().optional(),
68
+ enabled: z.boolean().optional(),
69
+ markdown: MarkdownConfigSchema.optional(),
70
+ botToken: z.string().optional(),
71
+ tokenFile: z.string().optional(),
72
+ dmPolicy: DmPolicySchema.optional().default('pairing'),
73
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
74
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
75
+ groupPolicy: GroupPolicySchema.optional().default('allowlist'),
76
+ groups: z.record(z.string(), MaxGroupSchema.optional()).optional(),
77
+ /** Update transport; default: "webhook" when webhookUrl is set, otherwise "polling" */
78
+ transport: z.enum(['polling', 'webhook']).optional(),
79
+ webhookUrl: z.string().optional(),
80
+ /** MAX secret: 5–256 of [A-Za-z0-9_-]; checked at start */
81
+ webhookSecret: z.string().optional(),
82
+ /** File holding the webhook secret (regular file, not a symlink) */
83
+ webhookSecretFile: z.string().optional(),
84
+ webhookPath: z.string().optional(),
85
+ historyLimit: z.number().int().min(0).optional(),
86
+ dmHistoryLimit: z.number().int().min(0).optional(),
87
+ dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
88
+ textChunkLimit: z.number().int().positive().optional(),
89
+ blockStreaming: z.boolean().optional(),
90
+ streamMode: z.enum(['off', 'partial', 'block']).optional(),
91
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
92
+ responsePrefix: z.string().optional(),
93
+ mediaMaxMb: z.number().positive().optional(),
94
+ markSeen: z.boolean().optional(),
95
+ actions: z
96
+ .record(
97
+ z.string(),
98
+ z.union([z.boolean(), z.enum(['pairing', 'allowlist', 'open'])]).optional(),
99
+ )
100
+ .optional(),
101
+ })
102
+ .strict();
103
+
104
+ function requireWebhookUrl(params: {
105
+ transport: string | undefined;
106
+ webhookUrl: string | undefined;
107
+ ctx: z.RefinementCtx;
108
+ }): void {
109
+ if (params.transport !== 'webhook' || params.webhookUrl?.trim()) return;
110
+ params.ctx.addIssue({
111
+ code: z.ZodIssueCode.custom,
112
+ path: ['webhookUrl'],
113
+ message: 'channels.max.transport="webhook" requires channels.max.webhookUrl',
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Individual account schema (with open-policy validation)
119
+ */
120
+ export const MaxAccountSchema = MaxAccountSchemaBase.superRefine((value, ctx) => {
121
+ requireOpenAllowFrom({
122
+ policy: value.dmPolicy,
123
+ allowFrom: value.allowFrom,
124
+ ctx,
125
+ path: ['allowFrom'],
126
+ message: 'channels.max.dmPolicy="open" requires channels.max.allowFrom to include "*"',
127
+ });
128
+ requireWebhookUrl({ transport: value.transport, webhookUrl: value.webhookUrl, ctx });
129
+ });
130
+
131
+ /** Bot command registered via PATCH /me/commands (name ≤64 chars without slash, description ≤128) */
132
+ const MaxBotCommandSchema = z
133
+ .object({
134
+ name: z.string().min(1).max(64),
135
+ description: z.string().max(128).optional(),
136
+ })
137
+ .strict();
138
+
139
+ /**
140
+ * Top-level MAX config schema (supports accounts.* sub-configs)
141
+ */
142
+ export const MaxConfigSchema = MaxAccountSchemaBase.extend({
143
+ accounts: z.record(z.string(), MaxAccountSchema.optional()).optional(),
144
+ commands: z.array(MaxBotCommandSchema).max(32).optional(),
145
+ }).superRefine((value, ctx) => {
146
+ requireOpenAllowFrom({
147
+ policy: value.dmPolicy,
148
+ allowFrom: value.allowFrom,
149
+ ctx,
150
+ path: ['allowFrom'],
151
+ message: 'channels.max.dmPolicy="open" requires channels.max.allowFrom to include "*"',
152
+ });
153
+ requireWebhookUrl({ transport: value.transport, webhookUrl: value.webhookUrl, ctx });
154
+ });