@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.
Files changed (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
@@ -2,13 +2,24 @@
2
2
  * MAX channel config Zod schema
3
3
  */
4
4
 
5
- import { MarkdownConfigSchema, ToolPolicySchema } from 'openclaw/plugin-sdk/channel-config-schema';
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
- // 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.
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
- botToken: z.string().optional(),
89
+ /** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
90
+ botToken: buildOptionalSecretInputSchema(),
71
91
  tokenFile: z.string().optional(),
72
- dmPolicy: DmPolicySchema.optional().default('pairing'),
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
- groupPolicy: GroupPolicySchema.optional().default('allowlist'),
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: z.string().optional(),
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, media albums and delivery.pin.
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 { readMaxDeliveryPin } from './presentation.js';
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
- readMaxChannelSendOptions,
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
- const sendOptions = readMaxChannelSendOptions(payload.channelData);
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
- if (params.callbackId && (payload.text || buttons?.length)) {
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
- const body = (err as { body?: unknown })?.body;
53
- log?.error(
54
- `[${account.accountId}] MAX callback answer failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
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 (payload.text) {
71
- const chunkLimit = 4000; // MAX message limit
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
- const sent = await sendMaxMessage(chatId, chunk, {
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
- const body = (err as { body?: unknown })?.body;
89
- log?.error(
90
- `[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
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
- const sent = await sendMaxMessage(chatId, '', {
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
- const body = (err as { body?: unknown })?.body;
107
- log?.error(
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 links are
121
- // sent by URL, other remote media is downloaded and uploaded.
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
- const pin = readMaxDeliveryPin(payload.delivery);
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
- /** channelData with `max.buttons` removed (other max options kept). */
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 sent once, by processIncomingMessage right before the agent
27
- // run — only for messages that pass the group/DM gates.
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
- if (!update.message) break;
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} text="${update.message?.body?.text ?? '<null>'}" hasBody=${!!update.message?.body}`,
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 editedMessage = { ...update.message };
82
- const originalMid = editedMessage.body.mid;
83
- editedMessage.body = {
84
- ...editedMessage.body,
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: "${fetchedMsg.body.text.slice(0, 50)}"`,
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
+ }