@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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access policy of the MAX channel: DM pairing/allowlist, group
|
|
3
|
+
* requireMention and tool policy, security warnings, pairing approval.
|
|
4
|
+
*/
|
|
5
|
+
import { resolveToolsBySender } from 'openclaw/plugin-sdk/channel-policy';
|
|
6
|
+
import { PAIRING_APPROVED_MESSAGE } from 'openclaw/plugin-sdk/channel-status';
|
|
7
|
+
import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
import { resolveMaxAccount } from './accounts.js';
|
|
9
|
+
import { sendMaxMessage } from './send.js';
|
|
10
|
+
// ── MAX group policy helpers ──
|
|
11
|
+
// These mirror resolveChannelGroupRequireMention/resolveChannelGroupToolsPolicy
|
|
12
|
+
// (internal SDK functions not exported) but for the "max" channel.
|
|
13
|
+
function resolveMaxGroupConfig(cfg, groupId, accountId) {
|
|
14
|
+
const maxSection = cfg.channels?.max;
|
|
15
|
+
if (!maxSection)
|
|
16
|
+
return { groupConfig: undefined, defaultConfig: undefined };
|
|
17
|
+
// Resolve groups map: account-level takes priority over channel-level
|
|
18
|
+
let groups;
|
|
19
|
+
if (accountId && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
20
|
+
const accounts = maxSection.accounts;
|
|
21
|
+
groups = accounts?.[accountId]?.groups;
|
|
22
|
+
}
|
|
23
|
+
if (!groups) {
|
|
24
|
+
groups = maxSection.groups;
|
|
25
|
+
}
|
|
26
|
+
const normalizedId = groupId?.trim();
|
|
27
|
+
const groupConfig = normalizedId && groups
|
|
28
|
+
? groups[normalizedId]
|
|
29
|
+
: undefined;
|
|
30
|
+
const defaultConfig = groups?.['*'];
|
|
31
|
+
return { groupConfig, defaultConfig };
|
|
32
|
+
}
|
|
33
|
+
function resolveMaxGroupRequireMention(params) {
|
|
34
|
+
const { groupConfig, defaultConfig } = resolveMaxGroupConfig(params.cfg, params.groupId, params.accountId);
|
|
35
|
+
const configMention = typeof groupConfig?.requireMention === 'boolean'
|
|
36
|
+
? groupConfig.requireMention
|
|
37
|
+
: typeof defaultConfig?.requireMention === 'boolean'
|
|
38
|
+
? defaultConfig.requireMention
|
|
39
|
+
: undefined;
|
|
40
|
+
if (typeof configMention === 'boolean')
|
|
41
|
+
return configMention;
|
|
42
|
+
return true; // default: require mention
|
|
43
|
+
}
|
|
44
|
+
function resolveMaxGroupToolPolicy(params) {
|
|
45
|
+
const { groupConfig, defaultConfig } = resolveMaxGroupConfig(params.cfg, params.groupId, params.accountId);
|
|
46
|
+
// Group-level sender-specific policy
|
|
47
|
+
const groupSenderPolicy = resolveToolsBySender({
|
|
48
|
+
toolsBySender: groupConfig?.toolsBySender,
|
|
49
|
+
senderId: params.senderId,
|
|
50
|
+
senderName: params.senderName,
|
|
51
|
+
senderUsername: params.senderUsername,
|
|
52
|
+
senderE164: params.senderE164,
|
|
53
|
+
});
|
|
54
|
+
if (groupSenderPolicy)
|
|
55
|
+
return groupSenderPolicy;
|
|
56
|
+
if (groupConfig?.tools)
|
|
57
|
+
return groupConfig.tools;
|
|
58
|
+
// Default config fallback
|
|
59
|
+
const defaultSenderPolicy = resolveToolsBySender({
|
|
60
|
+
toolsBySender: defaultConfig?.toolsBySender,
|
|
61
|
+
senderId: params.senderId,
|
|
62
|
+
senderName: params.senderName,
|
|
63
|
+
senderUsername: params.senderUsername,
|
|
64
|
+
senderE164: params.senderE164,
|
|
65
|
+
});
|
|
66
|
+
if (defaultSenderPolicy)
|
|
67
|
+
return defaultSenderPolicy;
|
|
68
|
+
if (defaultConfig?.tools)
|
|
69
|
+
return defaultConfig.tools;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** DM policy (default pairing) and warnings for open group policy. */
|
|
73
|
+
export const maxSecurityAdapter = {
|
|
74
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
75
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
76
|
+
const maxSection = cfg.channels?.max;
|
|
77
|
+
const useAccountPath = Boolean(maxSection?.accounts?.[resolvedAccountId]);
|
|
78
|
+
const basePath = useAccountPath
|
|
79
|
+
? `channels.max.accounts.${resolvedAccountId}.`
|
|
80
|
+
: 'channels.max.';
|
|
81
|
+
return {
|
|
82
|
+
policy: account.config.dmPolicy ?? 'pairing',
|
|
83
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
84
|
+
policyPath: `${basePath}dmPolicy`,
|
|
85
|
+
allowFromPath: basePath,
|
|
86
|
+
approveHint: formatPairingApproveHint('max'),
|
|
87
|
+
normalizeEntry: (raw) => raw.replace(/^max:/i, ''),
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
collectWarnings: ({ account, cfg }) => {
|
|
91
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
92
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
|
93
|
+
if (groupPolicy !== 'open') {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const groupAllowlistConfigured = account.config.groups && Object.keys(account.config.groups).length > 0;
|
|
97
|
+
if (groupAllowlistConfigured) {
|
|
98
|
+
return [
|
|
99
|
+
`- 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.`,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
return [
|
|
103
|
+
`- 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.`,
|
|
104
|
+
];
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
/** Group requireMention (default true) and tool policy from channels.max.groups. */
|
|
108
|
+
export const maxGroupsAdapter = {
|
|
109
|
+
resolveRequireMention: ({ cfg, groupId, accountId }) => resolveMaxGroupRequireMention({ cfg, groupId, accountId }),
|
|
110
|
+
resolveToolPolicy: ({ cfg, groupId, accountId, senderId, senderName, senderUsername, senderE164, }) => resolveMaxGroupToolPolicy({
|
|
111
|
+
cfg,
|
|
112
|
+
groupId,
|
|
113
|
+
accountId,
|
|
114
|
+
senderId,
|
|
115
|
+
senderName,
|
|
116
|
+
senderUsername,
|
|
117
|
+
senderE164,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
/** Pairing ids are MAX user ids; approval is announced to the user in MAX. */
|
|
121
|
+
export const maxPairingAdapter = {
|
|
122
|
+
idLabel: 'maxUserId',
|
|
123
|
+
normalizeAllowEntry: (entry) => entry.replace(/^max:/i, ''),
|
|
124
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
125
|
+
const account = resolveMaxAccount({ cfg });
|
|
126
|
+
if (!account.token)
|
|
127
|
+
throw new Error('MAX bot token not configured');
|
|
128
|
+
await sendMaxMessage(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
import { buildChannelConfigSchema } from 'openclaw/plugin-sdk/core';
|
|
11
|
+
import { maxMessageActions } from './actions.js';
|
|
12
|
+
import { maxAgentPromptAdapter } from './channel-agent-prompt.js';
|
|
13
|
+
import { maxConfigAdapter, maxSetupAdapter } from './channel-config.js';
|
|
14
|
+
import { maxDirectoryAdapter, maxMessagingAdapter } from './channel-directory.js';
|
|
15
|
+
import { maxGatewayAdapter, maxStatusAdapter } from './channel-lifecycle.js';
|
|
16
|
+
import { maxOutboundAdapter } from './channel-outbound.js';
|
|
17
|
+
import { maxGroupsAdapter, maxPairingAdapter, maxSecurityAdapter } from './channel-policy.js';
|
|
18
|
+
import { MaxConfigSchema } from './config-schema.js';
|
|
19
|
+
import { buildMaxModelBrowseChannelData, buildMaxModelsAddProviderChannelData, buildMaxModelsListChannelData, buildMaxModelsMenuChannelData, buildMaxModelsProviderChannelData, } from './model-buttons.js';
|
|
20
|
+
import { maxSetupWizard } from './onboarding.js';
|
|
21
|
+
// ── Meta ──
|
|
22
|
+
const maxMeta = {
|
|
23
|
+
id: 'max',
|
|
24
|
+
label: 'MAX',
|
|
25
|
+
selectionLabel: 'MAX Messenger',
|
|
26
|
+
docsPath: '/channels/max',
|
|
27
|
+
blurb: 'MAX messenger bot via platform-api2.max.ru. Supports DMs, groups, inline keyboards.',
|
|
28
|
+
order: 50,
|
|
29
|
+
aliases: ['max-messenger'],
|
|
30
|
+
};
|
|
31
|
+
// ── Channel Plugin ──
|
|
32
|
+
export const maxPlugin = {
|
|
33
|
+
id: 'max',
|
|
34
|
+
meta: maxMeta,
|
|
35
|
+
setupWizard: maxSetupWizard,
|
|
36
|
+
configSchema: buildChannelConfigSchema(MaxConfigSchema),
|
|
37
|
+
capabilities: {
|
|
38
|
+
chatTypes: ['direct', 'group', 'channel'],
|
|
39
|
+
reactions: false,
|
|
40
|
+
threads: false,
|
|
41
|
+
media: true,
|
|
42
|
+
nativeCommands: true,
|
|
43
|
+
blockStreaming: false,
|
|
44
|
+
edit: true,
|
|
45
|
+
// DELETE /messages backs the "delete" action; replies use link.type=reply.
|
|
46
|
+
unsend: true,
|
|
47
|
+
reply: true,
|
|
48
|
+
polls: false,
|
|
49
|
+
},
|
|
50
|
+
reload: { configPrefixes: ['channels.max'] },
|
|
51
|
+
commands: {
|
|
52
|
+
nativeCommandsAutoEnabled: true,
|
|
53
|
+
nativeSkillsAutoEnabled: true,
|
|
54
|
+
buildModelsMenuChannelData: buildMaxModelsMenuChannelData,
|
|
55
|
+
buildModelsProviderChannelData: buildMaxModelsProviderChannelData,
|
|
56
|
+
buildModelsAddProviderChannelData: buildMaxModelsAddProviderChannelData,
|
|
57
|
+
buildModelsListChannelData: buildMaxModelsListChannelData,
|
|
58
|
+
buildModelBrowseChannelData: buildMaxModelBrowseChannelData,
|
|
59
|
+
},
|
|
60
|
+
agentPrompt: maxAgentPromptAdapter,
|
|
61
|
+
config: maxConfigAdapter,
|
|
62
|
+
security: maxSecurityAdapter,
|
|
63
|
+
groups: maxGroupsAdapter,
|
|
64
|
+
pairing: maxPairingAdapter,
|
|
65
|
+
threading: {
|
|
66
|
+
resolveReplyToMode: () => 'first',
|
|
67
|
+
},
|
|
68
|
+
messaging: maxMessagingAdapter,
|
|
69
|
+
directory: maxDirectoryAdapter,
|
|
70
|
+
outbound: maxOutboundAdapter,
|
|
71
|
+
setup: maxSetupAdapter,
|
|
72
|
+
status: maxStatusAdapter,
|
|
73
|
+
gateway: maxGatewayAdapter,
|
|
74
|
+
// Message actions (send, edit, delete)
|
|
75
|
+
actions: maxMessageActions,
|
|
76
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAX channel config Zod schema
|
|
3
|
+
*/
|
|
4
|
+
import { MarkdownConfigSchema, ToolPolicySchema } from 'openclaw/plugin-sdk/channel-config-schema';
|
|
5
|
+
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.
|
|
10
|
+
export const DmPolicySchema = z.enum(['pairing', 'allowlist', 'open', 'disabled']);
|
|
11
|
+
export const GroupPolicySchema = z.enum(['open', 'disabled', 'allowlist']);
|
|
12
|
+
// These schemas were removed from the public plugin-sdk surface in OpenClaw 2026.3.x.
|
|
13
|
+
// Inlined here to stay compatible with both old and new runtimes.
|
|
14
|
+
const BlockStreamingCoalesceSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
minChars: z.number().int().optional(),
|
|
17
|
+
maxChars: z.number().int().optional(),
|
|
18
|
+
idleMs: z.number().int().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
const DmConfigSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
function requireOpenAllowFrom(params) {
|
|
27
|
+
if (params.policy !== 'open')
|
|
28
|
+
return;
|
|
29
|
+
const normalized = (params.allowFrom ?? []).map(String);
|
|
30
|
+
if (normalized.includes('*'))
|
|
31
|
+
return;
|
|
32
|
+
params.ctx.addIssue({
|
|
33
|
+
code: z.ZodIssueCode.custom,
|
|
34
|
+
path: params.path,
|
|
35
|
+
message: params.message,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Per-group config for MAX chats
|
|
40
|
+
*/
|
|
41
|
+
export const MaxGroupSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
requireMention: z.boolean().optional(),
|
|
44
|
+
tools: ToolPolicySchema.optional(),
|
|
45
|
+
skills: z.array(z.string()).optional(),
|
|
46
|
+
enabled: z.boolean().optional(),
|
|
47
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
48
|
+
systemPrompt: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
/**
|
|
52
|
+
* MAX account config (base schema for both top-level and accounts.*)
|
|
53
|
+
*/
|
|
54
|
+
export const MaxAccountSchemaBase = z
|
|
55
|
+
.object({
|
|
56
|
+
name: z.string().optional(),
|
|
57
|
+
enabled: z.boolean().optional(),
|
|
58
|
+
markdown: MarkdownConfigSchema.optional(),
|
|
59
|
+
botToken: z.string().optional(),
|
|
60
|
+
tokenFile: z.string().optional(),
|
|
61
|
+
dmPolicy: DmPolicySchema.optional().default('pairing'),
|
|
62
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
63
|
+
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
64
|
+
groupPolicy: GroupPolicySchema.optional().default('allowlist'),
|
|
65
|
+
groups: z.record(z.string(), MaxGroupSchema.optional()).optional(),
|
|
66
|
+
/** Update transport; default: "webhook" when webhookUrl is set, otherwise "polling" */
|
|
67
|
+
transport: z.enum(['polling', 'webhook']).optional(),
|
|
68
|
+
webhookUrl: z.string().optional(),
|
|
69
|
+
/** MAX secret: 5–256 of [A-Za-z0-9_-]; checked at start */
|
|
70
|
+
webhookSecret: z.string().optional(),
|
|
71
|
+
/** File holding the webhook secret (regular file, not a symlink) */
|
|
72
|
+
webhookSecretFile: z.string().optional(),
|
|
73
|
+
webhookPath: z.string().optional(),
|
|
74
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
75
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
76
|
+
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
77
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
78
|
+
blockStreaming: z.boolean().optional(),
|
|
79
|
+
streamMode: z.enum(['off', 'partial', 'block']).optional(),
|
|
80
|
+
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
81
|
+
responsePrefix: z.string().optional(),
|
|
82
|
+
mediaMaxMb: z.number().positive().optional(),
|
|
83
|
+
markSeen: z.boolean().optional(),
|
|
84
|
+
actions: z
|
|
85
|
+
.record(z.string(), z.union([z.boolean(), z.enum(['pairing', 'allowlist', 'open'])]).optional())
|
|
86
|
+
.optional(),
|
|
87
|
+
})
|
|
88
|
+
.strict();
|
|
89
|
+
function requireWebhookUrl(params) {
|
|
90
|
+
if (params.transport !== 'webhook' || params.webhookUrl?.trim())
|
|
91
|
+
return;
|
|
92
|
+
params.ctx.addIssue({
|
|
93
|
+
code: z.ZodIssueCode.custom,
|
|
94
|
+
path: ['webhookUrl'],
|
|
95
|
+
message: 'channels.max.transport="webhook" requires channels.max.webhookUrl',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Individual account schema (with open-policy validation)
|
|
100
|
+
*/
|
|
101
|
+
export const MaxAccountSchema = MaxAccountSchemaBase.superRefine((value, ctx) => {
|
|
102
|
+
requireOpenAllowFrom({
|
|
103
|
+
policy: value.dmPolicy,
|
|
104
|
+
allowFrom: value.allowFrom,
|
|
105
|
+
ctx,
|
|
106
|
+
path: ['allowFrom'],
|
|
107
|
+
message: 'channels.max.dmPolicy="open" requires channels.max.allowFrom to include "*"',
|
|
108
|
+
});
|
|
109
|
+
requireWebhookUrl({ transport: value.transport, webhookUrl: value.webhookUrl, ctx });
|
|
110
|
+
});
|
|
111
|
+
/** Bot command registered via PATCH /me/commands (name ≤64 chars without slash, description ≤128) */
|
|
112
|
+
const MaxBotCommandSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
name: z.string().min(1).max(64),
|
|
115
|
+
description: z.string().max(128).optional(),
|
|
116
|
+
})
|
|
117
|
+
.strict();
|
|
118
|
+
/**
|
|
119
|
+
* Top-level MAX config schema (supports accounts.* sub-configs)
|
|
120
|
+
*/
|
|
121
|
+
export const MaxConfigSchema = MaxAccountSchemaBase.extend({
|
|
122
|
+
accounts: z.record(z.string(), MaxAccountSchema.optional()).optional(),
|
|
123
|
+
commands: z.array(MaxBotCommandSchema).max(32).optional(),
|
|
124
|
+
}).superRefine((value, ctx) => {
|
|
125
|
+
requireOpenAllowFrom({
|
|
126
|
+
policy: value.dmPolicy,
|
|
127
|
+
allowFrom: value.allowFrom,
|
|
128
|
+
ctx,
|
|
129
|
+
path: ['allowFrom'],
|
|
130
|
+
message: 'channels.max.dmPolicy="open" requires channels.max.allowFrom to include "*"',
|
|
131
|
+
});
|
|
132
|
+
requireWebhookUrl({ transport: value.transport, webhookUrl: value.webhookUrl, ctx });
|
|
133
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply delivery for the inbound pipeline: callback answers, markdown chunks
|
|
3
|
+
* with the keyboard on the last one, media albums and delivery.pin.
|
|
4
|
+
*/
|
|
5
|
+
import { readMaxDeliveryPin } from './presentation.js';
|
|
6
|
+
import { getMaxRuntime } from './runtime.js';
|
|
7
|
+
import { answerMaxCallback, pinMaxMessage, readMaxChannelButtons, readMaxChannelSendOptions, sendMaxMediaGroup, sendMaxMessage, } from './send.js';
|
|
8
|
+
export async function deliverMaxReply(params) {
|
|
9
|
+
const { payload, account, chatId, config, log, statusSink } = params;
|
|
10
|
+
const core = getMaxRuntime();
|
|
11
|
+
const buttons = readMaxChannelButtons(payload.channelData);
|
|
12
|
+
const sendOptions = readMaxChannelSendOptions(payload.channelData);
|
|
13
|
+
if (params.callbackId && (payload.text || buttons?.length)) {
|
|
14
|
+
try {
|
|
15
|
+
await answerMaxCallback(params.callbackId, payload.text ?? '', {
|
|
16
|
+
token: account.token,
|
|
17
|
+
format: 'markdown',
|
|
18
|
+
buttons,
|
|
19
|
+
});
|
|
20
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const body = err?.body;
|
|
24
|
+
log?.error(`[${account.accountId}] MAX callback answer failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// delivery.pin: pin the first delivered message (first chunk).
|
|
29
|
+
let firstMessageId;
|
|
30
|
+
let firstChatType;
|
|
31
|
+
const noteDelivered = (messageId, chatType) => {
|
|
32
|
+
if (!firstMessageId && messageId) {
|
|
33
|
+
firstMessageId = messageId;
|
|
34
|
+
firstChatType = chatType;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
if (payload.text) {
|
|
38
|
+
const chunkLimit = 4000; // MAX message limit
|
|
39
|
+
const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
|
|
40
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
|
|
41
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
42
|
+
const chunk = chunks[index];
|
|
43
|
+
try {
|
|
44
|
+
const sent = await sendMaxMessage(chatId, chunk, {
|
|
45
|
+
token: account.token,
|
|
46
|
+
replyToMessageId: params.replyToId,
|
|
47
|
+
format: 'markdown',
|
|
48
|
+
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
49
|
+
...sendOptions,
|
|
50
|
+
});
|
|
51
|
+
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
52
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const body = err?.body;
|
|
56
|
+
log?.error(`[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (buttons?.length) {
|
|
61
|
+
try {
|
|
62
|
+
const sent = await sendMaxMessage(chatId, '', {
|
|
63
|
+
token: account.token,
|
|
64
|
+
replyToMessageId: params.replyToId,
|
|
65
|
+
format: 'markdown',
|
|
66
|
+
buttons,
|
|
67
|
+
...sendOptions,
|
|
68
|
+
});
|
|
69
|
+
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
70
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const body = err?.body;
|
|
74
|
+
log?.error(`[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Media URLs — upload and send
|
|
78
|
+
const mediaList = payload.mediaUrls?.length
|
|
79
|
+
? payload.mediaUrls
|
|
80
|
+
: payload.mediaUrl
|
|
81
|
+
? [payload.mediaUrl]
|
|
82
|
+
: [];
|
|
83
|
+
// Images/videos go as albums (up to 12 per message); https image links are
|
|
84
|
+
// sent by URL, other remote media is downloaded and uploaded.
|
|
85
|
+
if (mediaList.length) {
|
|
86
|
+
const sent = await sendMaxMediaGroup(chatId, '', mediaList, {
|
|
87
|
+
token: account.token,
|
|
88
|
+
replyToMessageId: params.replyToId,
|
|
89
|
+
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
90
|
+
...sendOptions,
|
|
91
|
+
onError: (err, failed) => log?.error(`[${account.accountId}] MAX media send failed (${failed.length} item(s)): ${String(err)}`),
|
|
92
|
+
});
|
|
93
|
+
for (const id of sent.messageIds)
|
|
94
|
+
noteDelivered(id);
|
|
95
|
+
if (sent.messageIds.length)
|
|
96
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
97
|
+
}
|
|
98
|
+
const pin = readMaxDeliveryPin(payload.delivery);
|
|
99
|
+
if (pin && firstMessageId) {
|
|
100
|
+
try {
|
|
101
|
+
const pinned = await pinMaxMessage(chatId, firstMessageId, {
|
|
102
|
+
token: account.token,
|
|
103
|
+
pinNotify: pin.notify === true,
|
|
104
|
+
chatType: firstChatType,
|
|
105
|
+
});
|
|
106
|
+
if (!pinned.pinned) {
|
|
107
|
+
log?.debug?.(`[${account.accountId}] MAX pin of ${firstMessageId} skipped: ${pinned.reason}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Optional pins degrade; the delivered message stays.
|
|
112
|
+
log?.[pin.required ? 'error' : 'warn'](`[${account.accountId}] MAX pin of ${firstMessageId} failed: ${String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** channelData with `max.buttons` removed (other max options kept). */
|
|
117
|
+
export function withoutMaxButtons(channelData) {
|
|
118
|
+
if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
|
|
119
|
+
return channelData;
|
|
120
|
+
const maxData = channelData.max;
|
|
121
|
+
if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData))
|
|
122
|
+
return channelData;
|
|
123
|
+
const rest = { ...maxData };
|
|
124
|
+
delete rest.buttons;
|
|
125
|
+
return { ...channelData, max: rest };
|
|
126
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update dispatch: routes each MAX update by update_type — messages and edits
|
|
3
|
+
* to the inbound pipeline, button presses to callbacks, bot/dialog lifecycle
|
|
4
|
+
* events to the chat registry.
|
|
5
|
+
*/
|
|
6
|
+
import { processCallback } from './callbacks.js';
|
|
7
|
+
import { processIncomingMessage } from './inbound.js';
|
|
8
|
+
/** mark_seen vanished from current MAX docs; keep behind config (default on). */
|
|
9
|
+
function shouldMarkSeen(account) {
|
|
10
|
+
return account.config.markSeen !== false;
|
|
11
|
+
}
|
|
12
|
+
function sendReadReceipt(chatId, opts) {
|
|
13
|
+
const { log, account } = opts;
|
|
14
|
+
if (!chatId)
|
|
15
|
+
return;
|
|
16
|
+
if (shouldMarkSeen(account)) {
|
|
17
|
+
opts.api.sendAction(chatId, 'mark_seen').catch((err) => {
|
|
18
|
+
log?.debug?.(`[${account.accountId}] mark_seen failed: ${String(err)}`);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// typing_on is sent once, by processIncomingMessage right before the agent
|
|
22
|
+
// run — only for messages that pass the group/DM gates.
|
|
23
|
+
}
|
|
24
|
+
/** Route one update by update_type (shared by the polling loop and the webhook queue). */
|
|
25
|
+
export async function dispatchUpdate(update, opts) {
|
|
26
|
+
const { log, account, statusSink } = opts;
|
|
27
|
+
switch (update.update_type) {
|
|
28
|
+
case 'message_created': {
|
|
29
|
+
if (!update.message)
|
|
30
|
+
break;
|
|
31
|
+
// Skip messages from the bot itself
|
|
32
|
+
if (opts.botUserId && update.message.sender?.user_id === opts.botUserId)
|
|
33
|
+
break;
|
|
34
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
35
|
+
// Mark message as read (typing starts once the message reaches the agent)
|
|
36
|
+
sendReadReceipt(update.message.recipient?.chat_id, opts);
|
|
37
|
+
// Passive chat discovery: GET /chats is deprecated, register group chats
|
|
38
|
+
// the bot actually sees so directory.listGroups keeps working.
|
|
39
|
+
const recipient = update.message.recipient;
|
|
40
|
+
if (opts.state &&
|
|
41
|
+
recipient?.chat_id != null &&
|
|
42
|
+
(recipient.chat_type === 'chat' || recipient.chat_type === 'channel') &&
|
|
43
|
+
!opts.state.hasActiveChat(recipient.chat_id)) {
|
|
44
|
+
// A message from this chat proves the bot is a member — (re)register it,
|
|
45
|
+
// clearing any stale removedAt from an earlier bot_removed/dialog_removed.
|
|
46
|
+
opts.state.upsertChat(recipient.chat_id, {
|
|
47
|
+
type: recipient.chat_type,
|
|
48
|
+
addedAt: Date.now(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
await processIncomingMessage(update.message, update.user_locale, opts);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'message_callback': {
|
|
55
|
+
if (!update.callback)
|
|
56
|
+
break;
|
|
57
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
58
|
+
await processCallback(update.callback, update.message ?? null, update.user_locale, opts);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'message_edited': {
|
|
62
|
+
if (!update.message)
|
|
63
|
+
break;
|
|
64
|
+
// Skip edits from the bot itself
|
|
65
|
+
if (opts.botUserId && update.message.sender?.user_id === opts.botUserId)
|
|
66
|
+
break;
|
|
67
|
+
log?.debug?.(`[${account.accountId}] Message edited: ${update.message?.body?.mid} text="${update.message?.body?.text ?? '<null>'}" hasBody=${!!update.message?.body}`);
|
|
68
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
69
|
+
// Mark as read (typing starts once the message reaches the agent)
|
|
70
|
+
sendReadReceipt(update.message.recipient?.chat_id, opts);
|
|
71
|
+
// Process edited message through the same pipeline as new messages.
|
|
72
|
+
// Use a unique mid suffix to avoid OpenClaw dedup (same mid = skipped).
|
|
73
|
+
const editedMessage = { ...update.message };
|
|
74
|
+
const originalMid = editedMessage.body.mid;
|
|
75
|
+
editedMessage.body = {
|
|
76
|
+
...editedMessage.body,
|
|
77
|
+
mid: `${originalMid}_edited_${update.timestamp}`,
|
|
78
|
+
};
|
|
79
|
+
// MAX message_edited may not include text — fetch it from API if missing
|
|
80
|
+
if (!editedMessage.body.text?.trim() && originalMid) {
|
|
81
|
+
try {
|
|
82
|
+
const chatId = editedMessage.recipient?.chat_id;
|
|
83
|
+
if (chatId) {
|
|
84
|
+
const fetched = await opts.api.getMessages(chatId, {
|
|
85
|
+
message_ids: [originalMid],
|
|
86
|
+
count: 1,
|
|
87
|
+
});
|
|
88
|
+
const fetchedMsg = fetched.messages?.[0];
|
|
89
|
+
if (fetchedMsg?.body?.text) {
|
|
90
|
+
editedMessage.body = { ...editedMessage.body, text: fetchedMsg.body.text };
|
|
91
|
+
if (fetchedMsg.body.attachments?.length) {
|
|
92
|
+
editedMessage.body.attachments = fetchedMsg.body.attachments;
|
|
93
|
+
}
|
|
94
|
+
log?.debug?.(`[${account.accountId}] Fetched edited text: "${fetchedMsg.body.text.slice(0, 50)}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
log?.debug?.(`[${account.accountId}] Failed to fetch edited message text: ${String(err)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await processIncomingMessage(editedMessage, update.user_locale, opts);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'bot_started': {
|
|
106
|
+
if (!update.user)
|
|
107
|
+
break;
|
|
108
|
+
log?.info(`[${account.accountId}] Bot started by user ${update.user.user_id}${update.payload ? ' (with deeplink payload)' : ''}`);
|
|
109
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
110
|
+
if (opts.state && update.chat_id != null) {
|
|
111
|
+
opts.state.upsertChat(update.chat_id, {
|
|
112
|
+
type: 'dialog',
|
|
113
|
+
addedAt: Date.now(),
|
|
114
|
+
stopped: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
await processBotStarted(update.user, update.chat_id, update.payload ?? undefined, opts);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'bot_stopped': {
|
|
121
|
+
// User halted the bot in a dialog — stop proactive sends until they return
|
|
122
|
+
log?.info(`[${account.accountId}] Bot stopped by user ${update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`);
|
|
123
|
+
if (opts.state && update.chat_id != null) {
|
|
124
|
+
opts.state.upsertChat(update.chat_id, { type: 'dialog', stopped: true });
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'bot_added': {
|
|
129
|
+
log?.info(`[${account.accountId}] Bot added to chat ${update.chat_id}`);
|
|
130
|
+
if (opts.state && update.chat_id != null) {
|
|
131
|
+
opts.state.upsertChat(update.chat_id, {
|
|
132
|
+
type: update.is_channel ? 'channel' : 'chat',
|
|
133
|
+
addedAt: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'bot_removed': {
|
|
139
|
+
log?.info(`[${account.accountId}] Bot removed from chat ${update.chat_id}`);
|
|
140
|
+
if (opts.state && update.chat_id != null) {
|
|
141
|
+
opts.state.upsertChat(update.chat_id, { removedAt: Date.now() });
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'dialog_removed': {
|
|
146
|
+
log?.info(`[${account.accountId}] Dialog removed by user ${update.user_id ?? update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`);
|
|
147
|
+
if (opts.state && update.chat_id != null) {
|
|
148
|
+
opts.state.upsertChat(update.chat_id, { removedAt: Date.now() });
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case 'dialog_cleared': {
|
|
153
|
+
// User wiped the dialog history on their side; keep our session but log it
|
|
154
|
+
log?.info(`[${account.accountId}] Dialog cleared by user ${update.user_id ?? update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'chat_title_changed': {
|
|
158
|
+
if (opts.state && update.chat_id != null && typeof update.title === 'string') {
|
|
159
|
+
opts.state.upsertChat(update.chat_id, { title: update.title });
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'message_removed': {
|
|
164
|
+
// Deliberate no-op: OpenClaw sessions have no per-message retraction.
|
|
165
|
+
log?.debug?.(`[${account.accountId}] Message removed in chat ${update.chat_id ?? '?'}: ${update.message_id ?? '?'}`);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
log?.debug?.(`[${account.accountId}] Unhandled update type: ${update.update_type}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function processBotStarted(user, chatId, payload, opts) {
|
|
173
|
+
// Synthesize a /start message; deeplink payload (max.ru/<bot>?start=...) is
|
|
174
|
+
// forwarded as the command argument like other messengers do.
|
|
175
|
+
const startText = payload?.trim() ? `/start ${payload.trim()}` : '/start';
|
|
176
|
+
const syntheticMessage = {
|
|
177
|
+
sender: user,
|
|
178
|
+
recipient: { chat_id: chatId ?? user.user_id, chat_type: 'dialog' },
|
|
179
|
+
timestamp: Date.now(),
|
|
180
|
+
body: {
|
|
181
|
+
mid: `bot_started_${user.user_id}_${Date.now()}`,
|
|
182
|
+
text: startText,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
await processIncomingMessage(syntheticMessage, null, opts);
|
|
186
|
+
}
|