@aspalagin/openclaw-max 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/src/config-schema.ts
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
* MAX channel config Zod schema
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ChannelPreviewStreamingConfigSchema,
|
|
7
|
+
MarkdownConfigSchema,
|
|
8
|
+
MentionPatternsPolicySchema,
|
|
9
|
+
ToolPolicySchema,
|
|
10
|
+
} from 'openclaw/plugin-sdk/channel-config-schema';
|
|
11
|
+
import { buildOptionalSecretInputSchema } from 'openclaw/plugin-sdk/secret-input';
|
|
6
12
|
import { z } from 'zod';
|
|
7
13
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
14
|
+
// Policy fields are built with this package's own zod: wrapping the SDK's enum
|
|
15
|
+
// instances in `.optional().default()` broke as soon as the plugin and the
|
|
16
|
+
// gateway resolved different zod copies (4.4 vs 4.6 → "expected nonoptional").
|
|
17
|
+
// Values mirror the SDK's DmPolicySchema/GroupPolicySchema.
|
|
18
|
+
//
|
|
19
|
+
// No field carries a schema default: the gateway writes JSON-schema defaults
|
|
20
|
+
// into the runtime config, also under accounts.<id>, and a default there would
|
|
21
|
+
// hide the channel-level value a named account inherits. The effective
|
|
22
|
+
// defaults (pairing, allowlist, …) are applied where the values are read.
|
|
12
23
|
export const DmPolicySchema = z.enum(['pairing', 'allowlist', 'open', 'disabled']);
|
|
13
24
|
export const GroupPolicySchema = z.enum(['open', 'disabled', 'allowlist']);
|
|
14
25
|
|
|
@@ -52,46 +63,134 @@ export const MaxGroupSchema = z
|
|
|
52
63
|
.object({
|
|
53
64
|
requireMention: z.boolean().optional(),
|
|
54
65
|
tools: ToolPolicySchema.optional(),
|
|
66
|
+
/** Skills a group turn may load (core skillFilter; [] = none). */
|
|
55
67
|
skills: z.array(z.string()).optional(),
|
|
68
|
+
/** false: the group is ignored under any groupPolicy. */
|
|
56
69
|
enabled: z.boolean().optional(),
|
|
57
70
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
71
|
+
/** Extra system prompt of group turns (core GroupSystemPrompt). */
|
|
58
72
|
systemPrompt: z.string().optional(),
|
|
73
|
+
/** Skip transcribing a captionless voice message to find a mention pattern. */
|
|
74
|
+
disableAudioPreflight: z.boolean().optional(),
|
|
59
75
|
})
|
|
60
76
|
.strict();
|
|
61
77
|
|
|
62
78
|
/**
|
|
63
|
-
* MAX account config (base schema for both top-level and accounts.*)
|
|
79
|
+
* MAX account config (base schema for both top-level and accounts.*).
|
|
80
|
+
* accounts.<id> inherits every channel-level value it does not set, except
|
|
81
|
+
* the per-bot keys (token, name, webhook; MAX_ACCOUNT_OWN_KEYS in accounts.ts).
|
|
64
82
|
*/
|
|
65
83
|
export const MaxAccountSchemaBase = z
|
|
66
84
|
.object({
|
|
67
85
|
name: z.string().optional(),
|
|
68
86
|
enabled: z.boolean().optional(),
|
|
87
|
+
/** No effect in MAX: neither the plugin nor core reads it for this channel. */
|
|
69
88
|
markdown: MarkdownConfigSchema.optional(),
|
|
70
|
-
|
|
89
|
+
/** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
|
|
90
|
+
botToken: buildOptionalSecretInputSchema(),
|
|
71
91
|
tokenFile: z.string().optional(),
|
|
72
|
-
|
|
92
|
+
/** Default "pairing" (applied where read); named accounts inherit the channel value. */
|
|
93
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
73
94
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
74
95
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
75
|
-
|
|
96
|
+
/** Default "allowlist" (applied where read); named accounts inherit the channel value. */
|
|
97
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
76
98
|
groups: z.record(z.string(), MaxGroupSchema.optional()).optional(),
|
|
99
|
+
/**
|
|
100
|
+
* Where core mention patterns (messages.groupChat / agent groupChat
|
|
101
|
+
* mentionPatterns) apply in MAX groups: mode allow|deny, allowIn, denyIn.
|
|
102
|
+
*/
|
|
103
|
+
mentionPatterns: MentionPatternsPolicySchema.optional(),
|
|
77
104
|
/** Update transport; default: "webhook" when webhookUrl is set, otherwise "polling" */
|
|
78
105
|
transport: z.enum(['polling', 'webhook']).optional(),
|
|
79
106
|
webhookUrl: z.string().optional(),
|
|
80
107
|
/** MAX secret: 5–256 of [A-Za-z0-9_-]; checked at start */
|
|
81
|
-
webhookSecret:
|
|
108
|
+
webhookSecret: buildOptionalSecretInputSchema(),
|
|
82
109
|
/** File holding the webhook secret (regular file, not a symlink) */
|
|
83
110
|
webhookSecretFile: z.string().optional(),
|
|
84
111
|
webhookPath: z.string().optional(),
|
|
112
|
+
/**
|
|
113
|
+
* Webhook queue: "durable" (default) journals every accepted update before
|
|
114
|
+
* MAX gets its 200, so a restart or crash does not lose it; "memory" keeps
|
|
115
|
+
* the 0.7 in-memory queue. maxPending (default 5000) bounds accepted but
|
|
116
|
+
* unhandled updates; when full, overflow "reject" (default) answers 503
|
|
117
|
+
* so MAX redelivers later, "drop" acknowledges and drops.
|
|
118
|
+
*/
|
|
119
|
+
webhookQueue: z
|
|
120
|
+
.object({
|
|
121
|
+
mode: z.enum(['durable', 'memory']).optional(),
|
|
122
|
+
maxPending: z.number().int().positive().optional(),
|
|
123
|
+
overflow: z.enum(['reject', 'drop']).optional(),
|
|
124
|
+
})
|
|
125
|
+
.strict()
|
|
126
|
+
.optional(),
|
|
127
|
+
/**
|
|
128
|
+
* Messages, edits, button presses and bot_started older than this many
|
|
129
|
+
* minutes (by MAX event time) are skipped instead of answered: after a
|
|
130
|
+
* long outage the bot does not reply to hours-old messages. Default 60;
|
|
131
|
+
* 0 answers any age.
|
|
132
|
+
*/
|
|
133
|
+
maxEventAgeMinutes: z.number().int().min(0).optional(),
|
|
134
|
+
/**
|
|
135
|
+
* Debug logs of inbound messages carry a text preview (50 characters).
|
|
136
|
+
* Off by default: logs hold message type, length and ids, never text.
|
|
137
|
+
*/
|
|
138
|
+
logMessagePreview: z.boolean().optional(),
|
|
139
|
+
/**
|
|
140
|
+
* Read by core, not the plugin: the embedded agent runtime keeps this many
|
|
141
|
+
* recent turns of a group session (historyLimit) or DM session
|
|
142
|
+
* (dmHistoryLimit, dms.<peer>.historyLimit) in the prompt.
|
|
143
|
+
*/
|
|
85
144
|
historyLimit: z.number().int().min(0).optional(),
|
|
86
145
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
87
146
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
|
147
|
+
/** Characters per outgoing message (core resolution), at most MAX's 4000. */
|
|
88
148
|
textChunkLimit: z.number().int().positive().optional(),
|
|
149
|
+
/** No effect in MAX: use streamMode / streaming.mode "block". */
|
|
89
150
|
blockStreaming: z.boolean().optional(),
|
|
90
151
|
streamMode: z.enum(['off', 'partial', 'block']).optional(),
|
|
152
|
+
/**
|
|
153
|
+
* Core streaming settings; streaming.mode wins over streamMode. "progress"
|
|
154
|
+
* shows one turn-status message (streaming.progress: label, toolProgress,
|
|
155
|
+
* maxLines, …) that is deleted once the answer landed. Default off.
|
|
156
|
+
*/
|
|
157
|
+
streaming: ChannelPreviewStreamingConfigSchema.optional(),
|
|
158
|
+
/** No effect in MAX: core reads streaming.block.coalesce instead. */
|
|
91
159
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
|
160
|
+
/** Prefix of agent replies, applied by core ("auto" = agent name). */
|
|
92
161
|
responsePrefix: z.string().optional(),
|
|
93
162
|
mediaMaxMb: z.number().positive().optional(),
|
|
163
|
+
/** Most media attachments downloaded per inbound message (default 12); the rest is noted. */
|
|
164
|
+
mediaMaxCount: z.number().int().positive().optional(),
|
|
94
165
|
markSeen: z.boolean().optional(),
|
|
166
|
+
/**
|
|
167
|
+
* Chats message-tool actions may act in: "admitted" (default) — the current
|
|
168
|
+
* chat, owner requests and chats admitted by the DM/group policy;
|
|
169
|
+
* "current" — non-owner turns only in their own chat; "off" — no check.
|
|
170
|
+
*/
|
|
171
|
+
actionScope: z.enum(['admitted', 'current', 'off']).optional(),
|
|
172
|
+
/**
|
|
173
|
+
* Send defaults; an explicit value of the call (core `silent`,
|
|
174
|
+
* channelData.max) wins. notify=false sends without push notifications
|
|
175
|
+
* (channels always notify).
|
|
176
|
+
*/
|
|
177
|
+
notify: z.boolean().optional(),
|
|
178
|
+
disableLinkPreview: z.boolean().optional(),
|
|
179
|
+
/**
|
|
180
|
+
* MAX Bot API base URL (default https://platform-api2.max.ru), e.g. a test
|
|
181
|
+
* stand; https only, http just for loopback hosts. Checked at start.
|
|
182
|
+
*/
|
|
183
|
+
apiBaseUrl: z.string().optional(),
|
|
184
|
+
/**
|
|
185
|
+
* HTTP(S) proxy for all MAX traffic of the account (API, uploads, media
|
|
186
|
+
* downloads), e.g. http://user:pass@host:3128; "" turns an inherited one
|
|
187
|
+
* off. Sensitive: credentials are never logged. Accepts a SecretRef.
|
|
188
|
+
*/
|
|
189
|
+
httpProxy: buildOptionalSecretInputSchema(),
|
|
190
|
+
/**
|
|
191
|
+
* Message-tool actions: false turns one off (hidden from the tool and
|
|
192
|
+
* refused). Other values, including the legacy policy strings, leave it on.
|
|
193
|
+
*/
|
|
95
194
|
actions: z
|
|
96
195
|
.record(
|
|
97
196
|
z.string(),
|
|
@@ -142,6 +241,8 @@ const MaxBotCommandSchema = z
|
|
|
142
241
|
export const MaxConfigSchema = MaxAccountSchemaBase.extend({
|
|
143
242
|
accounts: z.record(z.string(), MaxAccountSchema.optional()).optional(),
|
|
144
243
|
commands: z.array(MaxBotCommandSchema).max(32).optional(),
|
|
244
|
+
/** Accepted as in the manifest; the channel-level options are always the "default" account. */
|
|
245
|
+
defaultAccount: z.string().optional(),
|
|
145
246
|
}).superRefine((value, ctx) => {
|
|
146
247
|
requireOpenAllowFrom({
|
|
147
248
|
policy: value.dmPolicy,
|
package/src/deliver.ts
CHANGED
|
@@ -1,22 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reply delivery for the inbound pipeline: callback answers, markdown chunks
|
|
3
|
-
* with the keyboard on the last one
|
|
3
|
+
* with the keyboard on the last one (the first chunk may fill the stream
|
|
4
|
+
* draft), media albums and delivery.pin. Failures are rejected per the SDK
|
|
5
|
+
* delivery contract instead of being swallowed.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
9
|
+
import { createChannelPartialDeliveryError } from 'openclaw/plugin-sdk/channel-inbound';
|
|
7
10
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
11
|
+
import { PlatformMessageNotDispatchedError } from 'openclaw/plugin-sdk/error-runtime';
|
|
12
|
+
import { LocalMediaAccessError } from 'openclaw/plugin-sdk/web-media';
|
|
8
13
|
|
|
9
14
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
10
|
-
import {
|
|
15
|
+
import { MaxApiError } from './api.js';
|
|
16
|
+
import { readMaxDeliveryPin, resolveMaxTextChunkLimit } from './presentation.js';
|
|
11
17
|
import { getMaxRuntime } from './runtime.js';
|
|
12
18
|
import {
|
|
13
19
|
answerMaxCallback,
|
|
20
|
+
type MaxLocalMediaAccess,
|
|
14
21
|
pinMaxMessage,
|
|
15
22
|
readMaxChannelButtons,
|
|
16
|
-
|
|
23
|
+
resolveMaxSendFlags,
|
|
17
24
|
sendMaxMediaGroup,
|
|
18
25
|
sendMaxMessage,
|
|
19
26
|
} from './send.js';
|
|
27
|
+
import type { MaxDraftStream } from './stream-draft.js';
|
|
28
|
+
|
|
29
|
+
/** What already reached the chat when a later send failed. */
|
|
30
|
+
interface MaxVisibleDelivery {
|
|
31
|
+
messageIds: string[];
|
|
32
|
+
/** Text chunks the recipient can see, in order. */
|
|
33
|
+
texts: string[];
|
|
34
|
+
/** A button press was answered (POST /answers: visible, but no message id). */
|
|
35
|
+
callbackAnswered?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delivery failure per the SDK contract: once anything became visible (a
|
|
40
|
+
* chunk, an album, the stream draft) the error keeps that subset
|
|
41
|
+
* (createChannelPartialDeliveryError); a send MAX refused outright (4xx)
|
|
42
|
+
* proves nothing was dispatched (PlatformMessageNotDispatchedError, retryable
|
|
43
|
+
* only for 429), as does a local file refused by the media access policy;
|
|
44
|
+
* anything else (5xx, timeout, network) may have been applied and is
|
|
45
|
+
* rethrown as is.
|
|
46
|
+
* @internal exported for testing.
|
|
47
|
+
*/
|
|
48
|
+
export function toMaxDeliveryError(err: unknown, visible: MaxVisibleDelivery): unknown {
|
|
49
|
+
if (visible.messageIds.length > 0 || visible.callbackAnswered) {
|
|
50
|
+
const content = visible.texts.join('\n\n');
|
|
51
|
+
return createChannelPartialDeliveryError(err, {
|
|
52
|
+
visibleReplySent: true,
|
|
53
|
+
messageIds: [...visible.messageIds],
|
|
54
|
+
...(content ? { content } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (err instanceof MaxApiError && err.status >= 400 && err.status < 500) {
|
|
58
|
+
return new PlatformMessageNotDispatchedError(err.message, {
|
|
59
|
+
cause: err,
|
|
60
|
+
retryable: err.status === 429,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (err instanceof LocalMediaAccessError) {
|
|
64
|
+
return new PlatformMessageNotDispatchedError(err.message, { cause: err, retryable: false });
|
|
65
|
+
}
|
|
66
|
+
return err;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function logSendFailure(
|
|
70
|
+
account: ResolvedMaxAccount,
|
|
71
|
+
log: ChannelLogSink | undefined,
|
|
72
|
+
what: string,
|
|
73
|
+
err: unknown,
|
|
74
|
+
): void {
|
|
75
|
+
const body = (err as { body?: unknown })?.body;
|
|
76
|
+
log?.error(
|
|
77
|
+
`[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
20
80
|
|
|
21
81
|
export async function deliverMaxReply(params: {
|
|
22
82
|
payload: {
|
|
@@ -26,87 +86,138 @@ export async function deliverMaxReply(params: {
|
|
|
26
86
|
replyToId?: string;
|
|
27
87
|
channelData?: unknown;
|
|
28
88
|
delivery?: unknown;
|
|
89
|
+
/** Voice reply (TTS, [[audio_as_voice]]): the audio goes as a MAX voice message. */
|
|
90
|
+
audioAsVoice?: boolean;
|
|
29
91
|
};
|
|
30
92
|
account: ResolvedMaxAccount;
|
|
31
93
|
chatId: string;
|
|
32
94
|
replyToId?: string;
|
|
33
95
|
callbackId?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Shared by the payloads of one turn: a press is answered by the first of
|
|
98
|
+
* them only (a second answer would replace the first in the same message).
|
|
99
|
+
*/
|
|
100
|
+
callbackState?: { answered: boolean };
|
|
34
101
|
config: OpenClawConfig;
|
|
35
102
|
log?: ChannelLogSink;
|
|
36
103
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
104
|
+
/** Edit-streaming draft: the first text chunk replaces it instead of a new message. */
|
|
105
|
+
draft?: MaxDraftStream;
|
|
106
|
+
/** Allowed roots for local reply media (the agent's scoped media roots). */
|
|
107
|
+
localMedia?: MaxLocalMediaAccess;
|
|
108
|
+
/** Reply dispatcher kind of the payload: tool summaries go without link previews by default. */
|
|
109
|
+
kind?: 'tool' | 'block' | 'final';
|
|
37
110
|
}): Promise<void> {
|
|
38
111
|
const { payload, account, chatId, config, log, statusSink } = params;
|
|
39
112
|
const core = getMaxRuntime();
|
|
40
113
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
41
|
-
|
|
114
|
+
// channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
|
|
115
|
+
const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
|
|
116
|
+
// Tool summaries (verbose mode) quote paths and file names MAX would preview
|
|
117
|
+
// as links (`notes.md` reads as a domain); an explicit disableLinkPreview wins.
|
|
118
|
+
if (params.kind === 'tool' && sendOptions.disableLinkPreview === undefined) {
|
|
119
|
+
sendOptions.disableLinkPreview = true;
|
|
120
|
+
}
|
|
121
|
+
// A draft send still in flight settles first, so it is replaced, not orphaned.
|
|
122
|
+
if (params.draft) await params.draft.settle();
|
|
123
|
+
// Only the first text final replaces the draft: once it carries a final
|
|
124
|
+
// text, later final payloads of the turn go as new messages.
|
|
125
|
+
const draftMid =
|
|
126
|
+
payload.text && !params.draft?.finalized ? (params.draft?.messageId ?? undefined) : undefined;
|
|
127
|
+
|
|
128
|
+
// Everything the recipient can already see; a visible stream draft counts
|
|
129
|
+
// until it is replaced or deleted.
|
|
130
|
+
const visible: MaxVisibleDelivery = { messageIds: draftMid ? [draftMid] : [], texts: [] };
|
|
131
|
+
let failure: { err: unknown } | undefined;
|
|
42
132
|
|
|
43
|
-
|
|
133
|
+
// A button press is answered with the text and keyboard (POST /answers);
|
|
134
|
+
// the reply's media still follows in the chat below.
|
|
135
|
+
const answersCallback = Boolean(
|
|
136
|
+
params.callbackId &&
|
|
137
|
+
!params.callbackState?.answered &&
|
|
138
|
+
// A finalized draft carries the answer: the pressed message stays as is.
|
|
139
|
+
!params.draft?.finalized &&
|
|
140
|
+
!draftMid &&
|
|
141
|
+
(payload.text || buttons?.length),
|
|
142
|
+
);
|
|
143
|
+
if (answersCallback) {
|
|
144
|
+
if (params.callbackState) params.callbackState.answered = true;
|
|
44
145
|
try {
|
|
45
|
-
await answerMaxCallback(params.callbackId, payload.text ?? '', {
|
|
146
|
+
await answerMaxCallback(params.callbackId as string, payload.text ?? '', {
|
|
46
147
|
token: account.token,
|
|
47
148
|
format: 'markdown',
|
|
48
149
|
buttons,
|
|
49
150
|
});
|
|
151
|
+
visible.callbackAnswered = true;
|
|
152
|
+
if (payload.text) visible.texts.push(payload.text);
|
|
50
153
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
51
154
|
} catch (err: unknown) {
|
|
52
|
-
|
|
53
|
-
log
|
|
54
|
-
|
|
55
|
-
);
|
|
155
|
+
// Like a failed text chunk: the media still goes, then reject.
|
|
156
|
+
logSendFailure(account, log, 'callback answer', err);
|
|
157
|
+
failure = { err };
|
|
56
158
|
}
|
|
57
|
-
return;
|
|
58
159
|
}
|
|
59
160
|
|
|
60
161
|
// delivery.pin: pin the first delivered message (first chunk).
|
|
61
162
|
let firstMessageId: string | undefined;
|
|
62
163
|
let firstChatType: string | undefined;
|
|
63
|
-
const noteDelivered = (messageId: string, chatType?: string) => {
|
|
164
|
+
const noteDelivered = (messageId: string, chatType?: string, text?: string) => {
|
|
165
|
+
if (messageId && !visible.messageIds.includes(messageId)) visible.messageIds.push(messageId);
|
|
166
|
+
if (text) visible.texts.push(text);
|
|
64
167
|
if (!firstMessageId && messageId) {
|
|
65
168
|
firstMessageId = messageId;
|
|
66
169
|
firstChatType = chatType;
|
|
67
170
|
}
|
|
68
171
|
};
|
|
172
|
+
const sendTextMessage = async (text: string, messageButtons: typeof buttons) => {
|
|
173
|
+
const sent = await sendMaxMessage(chatId, text, {
|
|
174
|
+
token: account.token,
|
|
175
|
+
replyToMessageId: params.replyToId,
|
|
176
|
+
format: 'markdown',
|
|
177
|
+
buttons: messageButtons,
|
|
178
|
+
...sendOptions,
|
|
179
|
+
});
|
|
180
|
+
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type, text);
|
|
181
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
182
|
+
};
|
|
69
183
|
|
|
70
|
-
if (
|
|
71
|
-
|
|
184
|
+
if (answersCallback) {
|
|
185
|
+
// The text went with the callback answer.
|
|
186
|
+
} else if (payload.text) {
|
|
187
|
+
const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
|
|
72
188
|
const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
|
|
73
189
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
|
|
74
190
|
|
|
75
191
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
76
192
|
const chunk = chunks[index];
|
|
193
|
+
const chunkButtons = index === chunks.length - 1 ? buttons : undefined;
|
|
194
|
+
if (index === 0 && draftMid && params.draft) {
|
|
195
|
+
// The draft becomes the first chunk. If MAX refuses that edit, the
|
|
196
|
+
// stale partial draft is deleted and the answer goes as new messages.
|
|
197
|
+
if (await params.draft.finalize(chunk, chunkButtons)) {
|
|
198
|
+
noteDelivered(draftMid, undefined, chunk);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (await params.draft.discard()) {
|
|
202
|
+
visible.messageIds.splice(visible.messageIds.indexOf(draftMid), 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
77
205
|
try {
|
|
78
|
-
|
|
79
|
-
token: account.token,
|
|
80
|
-
replyToMessageId: params.replyToId,
|
|
81
|
-
format: 'markdown',
|
|
82
|
-
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
83
|
-
...sendOptions,
|
|
84
|
-
});
|
|
85
|
-
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
86
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
206
|
+
await sendTextMessage(chunk, chunkButtons);
|
|
87
207
|
} catch (err: unknown) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
208
|
+
// A later chunk after a gap would garble the answer: stop the text,
|
|
209
|
+
// still send the media (independent content), then reject.
|
|
210
|
+
logSendFailure(account, log, 'send', err);
|
|
211
|
+
failure = { err };
|
|
212
|
+
break;
|
|
92
213
|
}
|
|
93
214
|
}
|
|
94
215
|
} else if (buttons?.length) {
|
|
95
216
|
try {
|
|
96
|
-
|
|
97
|
-
token: account.token,
|
|
98
|
-
replyToMessageId: params.replyToId,
|
|
99
|
-
format: 'markdown',
|
|
100
|
-
buttons,
|
|
101
|
-
...sendOptions,
|
|
102
|
-
});
|
|
103
|
-
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
104
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
217
|
+
await sendTextMessage('', buttons);
|
|
105
218
|
} catch (err: unknown) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
`[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
|
|
109
|
-
);
|
|
219
|
+
logSendFailure(account, log, 'send', err);
|
|
220
|
+
failure = { err };
|
|
110
221
|
}
|
|
111
222
|
}
|
|
112
223
|
|
|
@@ -117,24 +228,41 @@ export async function deliverMaxReply(params: {
|
|
|
117
228
|
? [payload.mediaUrl]
|
|
118
229
|
: [];
|
|
119
230
|
|
|
120
|
-
// Images/videos go as albums (up to 12 per message); https image
|
|
121
|
-
// sent by URL, other remote media is downloaded
|
|
231
|
+
// Images/videos go as albums (up to 12 per message); public https image
|
|
232
|
+
// links are sent by URL, other remote media is downloaded (SSRF-guarded)
|
|
233
|
+
// and uploaded, local files are read only under the agent's media roots.
|
|
234
|
+
// Voice replies: the text (if any) went above as its own message(s), the
|
|
235
|
+
// audio follows without a caption; the text is never taken from the
|
|
236
|
+
// spoken text, so it reaches the chat once.
|
|
237
|
+
const asVoice = payload.audioAsVoice === true;
|
|
122
238
|
if (mediaList.length) {
|
|
123
239
|
const sent = await sendMaxMediaGroup(chatId, '', mediaList, {
|
|
124
240
|
token: account.token,
|
|
125
241
|
replyToMessageId: params.replyToId,
|
|
126
242
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
243
|
+
localMedia: params.localMedia,
|
|
244
|
+
asVoice,
|
|
127
245
|
...sendOptions,
|
|
128
|
-
onError: (err, failed) =>
|
|
246
|
+
onError: (err, failed) => {
|
|
129
247
|
log?.error(
|
|
130
248
|
`[${account.accountId}] MAX media send failed (${failed.length} item(s)): ${String(err)}`,
|
|
131
|
-
)
|
|
249
|
+
);
|
|
250
|
+
failure ??= { err };
|
|
251
|
+
},
|
|
132
252
|
});
|
|
133
253
|
for (const id of sent.messageIds) noteDelivered(id);
|
|
134
254
|
if (sent.messageIds.length) statusSink?.({ lastOutboundAt: Date.now() });
|
|
255
|
+
// A voice-only reply (no visible text) leaves a partial stream draft of
|
|
256
|
+
// the same answer behind: once the audio is delivered the draft goes; a
|
|
257
|
+
// draft that already carries a final text stays.
|
|
258
|
+
const draft = params.draft;
|
|
259
|
+
if (asVoice && !payload.text && sent.messageIds.length && draft?.messageId) {
|
|
260
|
+
if (!draft.finalized) await draft.discard();
|
|
261
|
+
}
|
|
135
262
|
}
|
|
136
263
|
|
|
137
|
-
|
|
264
|
+
// The message a callback answer changed has no id here to pin.
|
|
265
|
+
const pin = answersCallback ? undefined : readMaxDeliveryPin(payload.delivery);
|
|
138
266
|
if (pin && firstMessageId) {
|
|
139
267
|
try {
|
|
140
268
|
const pinned = await pinMaxMessage(chatId, firstMessageId, {
|
|
@@ -154,15 +282,6 @@ export async function deliverMaxReply(params: {
|
|
|
154
282
|
);
|
|
155
283
|
}
|
|
156
284
|
}
|
|
157
|
-
}
|
|
158
285
|
|
|
159
|
-
|
|
160
|
-
export function withoutMaxButtons(channelData: unknown): unknown {
|
|
161
|
-
if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
|
|
162
|
-
return channelData;
|
|
163
|
-
const maxData = (channelData as Record<string, unknown>).max;
|
|
164
|
-
if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return channelData;
|
|
165
|
-
const rest = { ...(maxData as Record<string, unknown>) };
|
|
166
|
-
delete rest.buttons;
|
|
167
|
-
return { ...(channelData as Record<string, unknown>), max: rest };
|
|
286
|
+
if (failure) throw toMaxDeliveryError(failure.err, visible);
|
|
168
287
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
8
8
|
import type { MaxMessage, MaxUpdate, MaxUser } from './api.js';
|
|
9
9
|
import { processCallback } from './callbacks.js';
|
|
10
|
-
import { processIncomingMessage } from './inbound.js';
|
|
10
|
+
import { describeMaxLogText, processIncomingMessage } from './inbound.js';
|
|
11
11
|
import type { MaxMonitorOptions } from './monitor-types.js';
|
|
12
12
|
|
|
13
13
|
/** mark_seen vanished from current MAX docs; keep behind config (default on). */
|
|
@@ -23,8 +23,8 @@ function sendReadReceipt(chatId: number | undefined, opts: MaxMonitorOptions): v
|
|
|
23
23
|
log?.debug?.(`[${account.accountId}] mark_seen failed: ${String(err)}`);
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
-
// typing_on is
|
|
27
|
-
//
|
|
26
|
+
// typing_on is kept up by processIncomingMessage for the agent turn — only
|
|
27
|
+
// for messages that pass the group/DM gates.
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Route one update by update_type (shared by the polling loop and the webhook queue). */
|
|
@@ -67,22 +67,23 @@ export async function dispatchUpdate(update: MaxUpdate, opts: MaxMonitorOptions)
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
case 'message_edited': {
|
|
70
|
-
|
|
70
|
+
// An edit always carries the edited body; a forward-only message
|
|
71
|
+
// (body null) cannot be edited.
|
|
72
|
+
if (!update.message?.body) break;
|
|
71
73
|
// Skip edits from the bot itself
|
|
72
74
|
if (opts.botUserId && update.message.sender?.user_id === opts.botUserId) break;
|
|
73
75
|
log?.debug?.(
|
|
74
|
-
`[${account.accountId}] Message edited: ${update.message?.body?.mid}
|
|
76
|
+
`[${account.accountId}] Message edited: ${update.message?.body?.mid} ${describeMaxLogText(account, update.message?.body?.text)} hasBody=${!!update.message?.body}`,
|
|
75
77
|
);
|
|
76
78
|
statusSink?.({ lastInboundAt: Date.now() });
|
|
77
79
|
// Mark as read (typing starts once the message reaches the agent)
|
|
78
80
|
sendReadReceipt(update.message.recipient?.chat_id, opts);
|
|
79
81
|
// Process edited message through the same pipeline as new messages.
|
|
80
82
|
// Use a unique mid suffix to avoid OpenClaw dedup (same mid = skipped).
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
...
|
|
85
|
-
mid: `${originalMid}_edited_${update.timestamp}`,
|
|
83
|
+
const originalMid = update.message.body.mid;
|
|
84
|
+
const editedMessage = {
|
|
85
|
+
...update.message,
|
|
86
|
+
body: { ...update.message.body, mid: `${originalMid}_edited_${update.timestamp}` },
|
|
86
87
|
};
|
|
87
88
|
|
|
88
89
|
// MAX message_edited may not include text — fetch it from API if missing
|
|
@@ -101,7 +102,7 @@ export async function dispatchUpdate(update: MaxUpdate, opts: MaxMonitorOptions)
|
|
|
101
102
|
editedMessage.body.attachments = fetchedMsg.body.attachments;
|
|
102
103
|
}
|
|
103
104
|
log?.debug?.(
|
|
104
|
-
`[${account.accountId}] Fetched edited text:
|
|
105
|
+
`[${account.accountId}] Fetched edited text: ${describeMaxLogText(account, fetchedMsg.body.text)}`,
|
|
105
106
|
);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core's refusal of new turns while the gateway drains for a stop or restart.
|
|
3
|
+
*
|
|
4
|
+
* During a graceful stop core keeps the channels running (up to its drain
|
|
5
|
+
* budget, minutes) but refuses to start new work with `GatewayDrainingError`.
|
|
6
|
+
* Refused before core adopted the turn, the update was never handled: the
|
|
7
|
+
* webhook queue and the polling loop keep it and retry after a pause until the
|
|
8
|
+
* channel stops; its journal row then stays on disk for the next process. The
|
|
9
|
+
* plugin SDK exports nothing for this error, so it is recognized by name.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sleepWithAbort } from 'openclaw/plugin-sdk/runtime-env';
|
|
13
|
+
|
|
14
|
+
const DRAINING_ERROR_NAME = 'GatewayDrainingError';
|
|
15
|
+
/** Causes followed below the error itself. */
|
|
16
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
17
|
+
/** Pauses between retries of a refused update: 2 s, 5 s, 10 s, then every 30 s. */
|
|
18
|
+
const DRAIN_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000];
|
|
19
|
+
|
|
20
|
+
/** True when `err` or one of its causes (up to 5 deep) is core's drain refusal. */
|
|
21
|
+
export function isMaxGatewayDrainingError(err: unknown): boolean {
|
|
22
|
+
let current = err;
|
|
23
|
+
for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) {
|
|
24
|
+
if (typeof current !== 'object' || current === null) return false;
|
|
25
|
+
if ((current as { name?: unknown }).name === DRAINING_ERROR_NAME) return true;
|
|
26
|
+
current = (current as { cause?: unknown }).cause;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pause before the retry after the `refusals`-th refusal in a row (1-based). */
|
|
32
|
+
export function maxDrainRetryDelayMs(refusals: number): number {
|
|
33
|
+
const index = Math.min(Math.max(refusals, 1), DRAIN_RETRY_DELAYS_MS.length) - 1;
|
|
34
|
+
return DRAIN_RETRY_DELAYS_MS[index] as number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Waits out the pause after a refusal; false when the channel stopped meanwhile (at once). */
|
|
38
|
+
export async function waitForMaxDrainRetry(
|
|
39
|
+
refusals: number,
|
|
40
|
+
abortSignal: AbortSignal,
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
if (abortSignal.aborted) return false;
|
|
43
|
+
await sleepWithAbort(maxDrainRetryDelayMs(refusals), abortSignal).catch(() => undefined);
|
|
44
|
+
return !abortSignal.aborted;
|
|
45
|
+
}
|