@aspalagin/openclaw-max 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
package/src/inbound.ts CHANGED
@@ -3,18 +3,53 @@
3
3
  * reply dispatch (finalizeInboundContext → dispatchReplyWithBufferedBlockDispatcher).
4
4
  */
5
5
 
6
- import { toInboundMediaFacts } from 'openclaw/plugin-sdk/channel-inbound';
7
- import { createReplyPrefixOptions } from 'openclaw/plugin-sdk/channel-outbound';
8
-
9
- import type { MaxMessage, MaxUser } from './api.js';
10
- import { deliverMaxReply, withoutMaxButtons } from './deliver.js';
11
- import { collectInboundAttachments } from './inbound-attachments.js';
6
+ import { matchesMentionPatterns, toInboundMediaFacts } from 'openclaw/plugin-sdk/channel-inbound';
7
+ import {
8
+ createReplyPrefixOptions,
9
+ resolveChannelPreviewStreamMode,
10
+ } from 'openclaw/plugin-sdk/channel-outbound';
11
+ import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
12
+
13
+ import { admitMaxGroupChat, admitMaxGroupSender, readMaxDmAllowFrom } from './access-policy.js';
14
+ import type { ResolvedMaxAccount } from './accounts.js';
15
+ import type { MaxAttachment, MaxLinkedMessage, MaxMessage, MaxUser } from './api.js';
16
+ import { resolveMaxCommandMenu } from './command-menu.js';
17
+ import { deliverMaxReply } from './deliver.js';
18
+ import {
19
+ echoMaxVoiceTranscript,
20
+ type MaxVoiceMention,
21
+ resolveMaxMentionRegexes,
22
+ resolveMaxVoiceMention,
23
+ } from './group-mention.js';
24
+ import { collectInboundAttachments, resolveInboundMediaMaxCount } from './inbound-attachments.js';
12
25
  import type { MaxMonitorOptions } from './monitor-types.js';
13
26
  import { materializeMaxPresentation } from './presentation.js';
27
+ import { createMaxProgressDraft } from './progress-draft.js';
14
28
  import { getMaxRuntime } from './runtime.js';
15
- import { readMaxChannelButtons, sendMaxMessage } from './send.js';
29
+ import { sendMaxMessage } from './send.js';
16
30
  import { createMaxDraftStream } from './stream-draft.js';
31
+ import { maxTurnAdoptionReplyOptions } from './turn-adoption.js';
17
32
  import type { MaxMarkupElement } from './types.js';
33
+ import { createMaxTypingCallbacks } from './typing.js';
34
+
35
+ /** Characters of message text in debug logs with logMessagePreview on. */
36
+ const LOG_PREVIEW_CHARS = 50;
37
+
38
+ /**
39
+ * Message text as logs may show it: its length only, unless the account
40
+ * enables logMessagePreview (a short preview for debugging).
41
+ */
42
+ export function describeMaxLogText(
43
+ account: ResolvedMaxAccount,
44
+ text: string | null | undefined,
45
+ ): string {
46
+ const length = text?.length ?? 0;
47
+ if (account.config.logMessagePreview !== true || !text) return `textLength=${length}`;
48
+ return `text="${text.slice(0, LOG_PREVIEW_CHARS)}" textLength=${length}`;
49
+ }
50
+
51
+ /** Longest quote of a replied-to message handed to the agent. */
52
+ const MAX_REPLY_QUOTE_CHARS = 1000;
18
53
 
19
54
  /**
20
55
  * Process incoming MAX message through OpenClaw pipeline (also the target of
@@ -37,28 +72,73 @@ export async function processIncomingMessage(
37
72
  const chatType = message.recipient.chat_type; // "dialog", "chat", "channel"
38
73
  const isGroup = chatType === 'chat' || chatType === 'channel';
39
74
 
40
- const rawText = message.body.text ?? '';
41
- const messageId = message.body.mid;
75
+ // body is null when the message is only a forward; the content then lives
76
+ // in link.message. Such a message has no mid of its own: a stable synthetic
77
+ // id keeps redelivery dedup working, and replies go out without a link.
78
+ const messageBody = message.body ?? undefined;
79
+ const rawText = messageBody?.text ?? '';
80
+ const messageId =
81
+ messageBody?.mid ??
82
+ `link_${chatId ?? senderId}_${message.timestamp}_${message.link?.message?.mid ?? ''}`;
42
83
  const isCallbackCommand =
43
84
  (message as MaxMessage & { __maxCallback?: boolean }).__maxCallback === true;
44
- const attachments = message.body.attachments ?? [];
85
+ const isCommandMenuPress =
86
+ isCallbackCommand &&
87
+ (message as MaxMessage & { __maxCommandMenu?: boolean }).__maxCommandMenu === true;
88
+ const attachments = messageBody?.attachments ?? [];
89
+ const forward = readForwardedMessage(message.link);
45
90
 
46
91
  log?.debug?.(
47
- `[${account.accountId}] Processing message: mid=${messageId} chatId=${message.recipient.chat_id} chatType=${message.recipient.chat_type} senderId=${message.sender?.user_id} text="${rawText.slice(0, 50)}" attachments=${attachments.length}`,
92
+ `[${account.accountId}] Processing message: mid=${messageId} chatId=${message.recipient.chat_id} chatType=${message.recipient.chat_type} senderId=${message.sender?.user_id} ${describeMaxLogText(account, rawText)} attachments=${attachments.length}`,
48
93
  );
49
94
 
50
95
  // Skip truly empty messages (no text and no attachment that yields media or
51
- // a description; inline keyboards yield neither). Attachments are
52
- // downloaded only after the DM/group gates below, so ignored group chatter
53
- // costs no traffic or disk.
54
- if (!rawText.trim() && !attachments.some((att) => att.type !== 'inline_keyboard')) return;
96
+ // a description; inline keyboards yield neither), counting forwarded
97
+ // content. Attachments are downloaded only after the DM/group gates below,
98
+ // so ignored group chatter costs no traffic or disk.
99
+ if (
100
+ !rawText.trim() &&
101
+ !hasAgentAttachments(attachments) &&
102
+ !(forward && (forward.text.trim() || hasAgentAttachments(forward.attachments)))
103
+ ) {
104
+ // A message with no text and no attachments at all is what some clients
105
+ // reportedly get in long polling for a voice message: leave a trace.
106
+ if (!attachments.length && !forward && !isCallbackCommand) {
107
+ log?.info?.(
108
+ `[${account.accountId}] Skipping empty message ${messageId}: no text, attachments or forward`,
109
+ );
110
+ }
111
+ return;
112
+ }
55
113
 
56
- // Check for reply context
57
- const replyToId = message.link?.type === 'reply' ? message.link.message?.body?.mid : undefined;
114
+ // Check for reply context (link.message is the replied-to MessageBody)
115
+ const reply = message.link?.type === 'reply' ? message.link : undefined;
116
+ const replyToId = reply?.message?.mid ?? undefined;
117
+
118
+ // Resolve agent route (before the mention check: configured mention
119
+ // patterns may be the routed agent's own).
120
+ // chatIdStr stays the delivery address (MAX addresses replies by chat_id).
121
+ const chatIdStr = String(chatId ?? senderId);
122
+ // DM routing keys off the sender's user_id, not the dialog's chat_id: in MAX
123
+ // the two differ, and bindings/allowFrom are expressed in user_id terms, so a
124
+ // chat_id peer never matches. Groups keep chat_id — it is the group's own id.
125
+ const routePeerId = isGroup ? chatIdStr : String(senderId ?? chatId);
126
+ const route = core.channel.routing.resolveAgentRoute({
127
+ cfg: config,
128
+ channel: 'max',
129
+ accountId: account.accountId,
130
+ peer: {
131
+ kind: isGroup ? 'group' : 'direct',
132
+ id: routePeerId,
133
+ },
134
+ });
58
135
 
59
136
  // Check for bot mention in group chats
60
137
  let wasMentioned: boolean | undefined;
61
- if (isGroup && (opts.botUsername || opts.botUserId)) {
138
+ const mentionRegexes = isGroup
139
+ ? resolveMaxMentionRegexes({ cfg: config, agentId: route.agentId, account, chatId: chatIdStr })
140
+ : [];
141
+ if (isGroup && (opts.botUsername || opts.botUserId || mentionRegexes.length > 0)) {
62
142
  // body.markup marks mentions as user_mention elements; the @botname regex
63
143
  // stays as a fallback for clients/messages that send no markup.
64
144
  wasMentioned = isBotMentionedInMarkup(message.body?.markup, opts.botUserId, opts.botUsername);
@@ -66,6 +146,11 @@ export async function processIncomingMessage(
66
146
  const mentionPattern = new RegExp(`@${escapeRegExp(opts.botUsername)}\\b`, 'i');
67
147
  wasMentioned = mentionPattern.test(rawText);
68
148
  }
149
+ // Core mention patterns (messages.groupChat / agent groupChat), only when
150
+ // configured; the sender's own text, not forwarded content.
151
+ if (!wasMentioned && mentionRegexes.length > 0) {
152
+ wasMentioned = matchesMentionPatterns(rawText, mentionRegexes);
153
+ }
69
154
 
70
155
  // Reply to bot's message also counts as mention (like Telegram behavior)
71
156
  if (!wasMentioned && message.link?.type === 'reply') {
@@ -92,11 +177,7 @@ export async function processIncomingMessage(
92
177
  }
93
178
 
94
179
  if (dmPolicy !== 'open') {
95
- const configAllowFrom = (account.config.allowFrom ?? []).map(String);
96
- const storeAllowFrom = await core.channel.pairing
97
- .readAllowFromStore({ channel: 'max', accountId: account.accountId })
98
- .catch(() => []);
99
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
180
+ const effectiveAllowFrom = await readMaxDmAllowFrom(account);
100
181
 
101
182
  const senderStr = String(senderId);
102
183
  const allowed = effectiveAllowFrom.includes(senderStr) || effectiveAllowFrom.includes('*');
@@ -131,73 +212,108 @@ export async function processIncomingMessage(
131
212
  }
132
213
  }
133
214
 
134
- // Group policy
215
+ // Group policy (disabled / allowlist incl. "*" / open)
216
+ let voiceMention: MaxVoiceMention | undefined;
135
217
  if (isGroup) {
136
- const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
137
- const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
138
-
139
- if (groupPolicy === 'disabled') {
140
- log?.debug?.(`[${account.accountId}] Blocked group message (groupPolicy=disabled)`);
218
+ const admission = admitMaxGroupChat(account, config, chatId);
219
+ if (!admission.admitted) {
220
+ log?.debug?.(
221
+ `[${account.accountId}] Blocked group message (${admission.reason}, chat=${String(chatId)})`,
222
+ );
141
223
  return;
142
224
  }
143
-
144
- // For allowlist policy, check if chat is in the groups config
145
- if (groupPolicy === 'allowlist') {
146
- const groups = account.config.groups ?? {};
147
- const chatIdStr = String(chatId);
148
- const hasWildcard = '*' in groups;
149
- const chatAllowed = chatIdStr in groups || hasWildcard;
150
- if (!chatAllowed) {
151
- log?.debug?.(
152
- `[${account.accountId}] Blocked group message (not in allowlist, chat=${chatIdStr})`,
153
- );
154
- return;
155
- }
225
+ // Sender allowlist (groups.<id>.allowFrom / groupAllowFrom): messages,
226
+ // button presses and commands alike; attachments are not downloaded.
227
+ const senderAdmission = admitMaxGroupSender(account, chatId, senderId);
228
+ if (!senderAdmission.admitted) {
229
+ log?.debug?.(
230
+ `[${account.accountId}] Blocked group message (${senderAdmission.reason}, chat=${String(chatId)}, sender=${String(senderId)})`,
231
+ );
232
+ return;
156
233
  }
157
234
 
158
235
  // Require mention in groups
159
236
  const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
160
237
  const requireMention = groupCfg?.requireMention ?? true;
161
238
  if (requireMention && !wasMentioned) {
162
- log?.debug?.(`[${account.accountId}] Skipping group message (not mentioned)`);
163
- return;
239
+ // A captionless voice message may still name the bot (mention patterns
240
+ // matched against its transcript).
241
+ voiceMention =
242
+ mentionRegexes.length > 0 && !rawText.trim() && groupCfg?.disableAudioPreflight !== true
243
+ ? await resolveMaxVoiceMention({
244
+ attachments,
245
+ messageId,
246
+ chatId,
247
+ chatIdStr,
248
+ senderId: String(senderId),
249
+ mentionRegexes,
250
+ cfg: config,
251
+ api: opts.api,
252
+ account,
253
+ log,
254
+ })
255
+ : undefined;
256
+ if (!voiceMention) {
257
+ log?.debug?.(`[${account.accountId}] Skipping group message (not mentioned)`);
258
+ return;
259
+ }
260
+ wasMentioned = true;
164
261
  }
165
262
  }
166
263
 
167
264
  // Process attachments: download media, build descriptions for non-downloadable types
168
- const { descriptions: attachmentDescriptions, mediaInputs } = await collectInboundAttachments({
265
+ // (a voice mention check may have done it already).
266
+ const {
267
+ descriptions: attachmentDescriptions,
268
+ mediaInputs: ownMediaInputs,
269
+ mediaTaken: ownMediaTaken,
270
+ } = voiceMention?.attachments ??
271
+ (await collectInboundAttachments({
169
272
  attachments,
170
273
  messageId,
171
274
  chatId,
172
275
  api: opts.api,
173
276
  account,
174
277
  log,
175
- });
278
+ }));
279
+ if (voiceMention?.transcript) {
280
+ // Core's transcript echo, deferred until the message passed the gate.
281
+ echoMaxVoiceTranscript({
282
+ cfg: config,
283
+ accountId: account.accountId,
284
+ chatId: chatIdStr,
285
+ transcript: voiceMention.transcript,
286
+ }).catch((err: unknown) => {
287
+ log?.debug?.(`[${account.accountId}] Voice transcript echo failed: ${String(err)}`);
288
+ });
289
+ }
290
+ // Forwarded attachments pass the same gates and download limits as the
291
+ // sender's own (one media count budget per message); their descriptions
292
+ // stay inside the forwarded block.
293
+ const forwarded = forward
294
+ ? await collectInboundAttachments({
295
+ attachments: forward.attachments,
296
+ messageId,
297
+ chatId,
298
+ api: opts.api,
299
+ account,
300
+ log,
301
+ mediaBudget: Math.max(0, resolveInboundMediaMaxCount(account) - ownMediaTaken),
302
+ })
303
+ : undefined;
304
+ const mediaInputs = [...ownMediaInputs, ...(forwarded?.mediaInputs ?? [])];
305
+ const forwardBlock =
306
+ forward && forwarded
307
+ ? formatForwardBlock(forward, forwarded.descriptions, forwarded.mediaInputs.length > 0)
308
+ : '';
176
309
 
177
310
  const attachmentText = attachmentDescriptions.join(' ');
178
311
  const hasMedia = mediaInputs.length > 0;
179
- const effectiveText = rawText.trim() || attachmentText;
312
+ const effectiveText = rawText.trim() || attachmentText || forwardBlock;
180
313
 
181
314
  // Nothing usable came out of the attachments (e.g. a failed sticker download)
182
315
  if (!effectiveText && !hasMedia) return;
183
316
 
184
- // Resolve agent route
185
- // chatIdStr stays the delivery address (MAX addresses replies by chat_id).
186
- const chatIdStr = String(chatId ?? senderId);
187
- // DM routing keys off the sender's user_id, not the dialog's chat_id: in MAX
188
- // the two differ, and bindings/allowFrom are expressed in user_id terms, so a
189
- // chat_id peer never matches. Groups keep chat_id — it is the group's own id.
190
- const routePeerId = isGroup ? chatIdStr : String(senderId ?? chatId);
191
- const route = core.channel.routing.resolveAgentRoute({
192
- cfg: config,
193
- channel: 'max',
194
- accountId: account.accountId,
195
- peer: {
196
- kind: isGroup ? 'group' : 'direct',
197
- id: routePeerId,
198
- },
199
- });
200
-
201
317
  // Build context
202
318
  const fromLabel = isGroup ? `chat:${chatIdStr}` : senderName || `user:${senderId}`;
203
319
 
@@ -210,12 +326,18 @@ export async function processIncomingMessage(
210
326
  sessionKey: route.sessionKey,
211
327
  });
212
328
 
213
- // Combine text and attachment descriptions for the agent
214
- const bodyForAgent = attachmentText
329
+ // Combine text and attachment descriptions for the agent; a forward follows
330
+ // the sender's own text as a marked block.
331
+ const ownBodyForAgent = attachmentText
215
332
  ? rawText.trim()
216
333
  ? `${rawText.trim()}\n${attachmentText}`
217
334
  : attachmentText
218
335
  : rawText;
336
+ const bodyForAgent = forwardBlock
337
+ ? ownBodyForAgent.trim()
338
+ ? `${ownBodyForAgent.trim()}\n\n${forwardBlock}`
339
+ : forwardBlock
340
+ : ownBodyForAgent;
219
341
 
220
342
  const body = core.channel.reply.formatAgentEnvelope({
221
343
  channel: 'MAX',
@@ -230,11 +352,19 @@ export async function processIncomingMessage(
230
352
  const rawTextTrimmed = (rawText || '').trim();
231
353
  const isTextSlashCommand = rawTextTrimmed.startsWith('/');
232
354
 
355
+ // groups.<id> (else "*"): systemPrompt joins the group turn's system prompt,
356
+ // skills limits the skills the turn loads (core GroupSystemPrompt / skillFilter).
357
+ const groupEntry = isGroup
358
+ ? (account.config.groups?.[chatIdStr] ?? account.config.groups?.['*'])
359
+ : undefined;
360
+
233
361
  const ctxPayload = core.channel.reply.finalizeInboundContext({
234
362
  Body: body,
235
363
  BodyForAgent: bodyForAgent,
236
364
  RawBody: rawText,
237
- CommandBody: rawText || attachmentText,
365
+ // Forwarded text is someone else's words: never parse it as a command or
366
+ // an inline directive, only the sender's own text.
367
+ CommandBody: rawText || (forward ? '' : attachmentText),
238
368
  From: `max:${senderId}`,
239
369
  To: `max:${chatIdStr}`,
240
370
  SessionKey: route.sessionKey,
@@ -245,12 +375,14 @@ export async function processIncomingMessage(
245
375
  SenderId: senderId != null ? String(senderId) : undefined,
246
376
  SenderUsername: senderUsername,
247
377
  WasMentioned: isGroup ? wasMentioned : undefined,
378
+ GroupSystemPrompt: groupEntry?.systemPrompt?.trim() || undefined,
248
379
  Provider: 'max',
249
380
  Surface: 'max',
250
381
  MessageSid: messageId,
251
382
  MessageSidFull: messageId,
252
383
  ReplyToId: replyToId,
253
384
  ReplyToIdFull: replyToId,
385
+ SupplementalContext: buildSupplementalContext(reply, replyToId, forward),
254
386
  OriginatingChannel: 'max',
255
387
  OriginatingTo: `max:${chatIdStr}`,
256
388
  // Media attachments (downloaded to local paths) as ordered media facts
@@ -270,6 +402,52 @@ export async function processIncomingMessage(
270
402
  : {}),
271
403
  });
272
404
 
405
+ const replyMid =
406
+ isCallbackCommand || !messageBody ? undefined : messageId.replace(/_edited_\d+$/, '');
407
+ const callbackId = isCallbackCommand ? messageId : undefined;
408
+
409
+ // A bare command with a core argument menu (/think, /fast, …) opens it as
410
+ // buttons. A menu press is answered here when the sender has no command
411
+ // rights or core no longer offers the choice; otherwise it goes to core as
412
+ // the typed command would.
413
+ if (isTextSlashCommand) {
414
+ const menu = resolveMaxCommandMenu({
415
+ text: rawTextTrimmed,
416
+ press: isCommandMenuPress,
417
+ ctx: ctxPayload,
418
+ cfg: config,
419
+ agentId: route.agentId,
420
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
421
+ botUsername: opts.botUsername,
422
+ });
423
+ if (menu.kind === 'menu') {
424
+ await deliverMaxReply({
425
+ payload: { text: menu.text, channelData: { max: { buttons: menu.buttons } } },
426
+ account,
427
+ chatId: chatIdStr,
428
+ replyToId: replyMid,
429
+ callbackId,
430
+ config,
431
+ log,
432
+ statusSink,
433
+ });
434
+ return;
435
+ }
436
+ if (menu.kind !== 'dispatch') {
437
+ if (callbackId) {
438
+ const notification =
439
+ menu.kind === 'denied'
440
+ ? 'You are not allowed to use this command.'
441
+ : 'This menu is out of date. Send the command again.';
442
+ await opts.api.answerCallback(callbackId, { notification }).catch((err: unknown) => {
443
+ log?.debug?.(`[${account.accountId}] MAX callback answer failed: ${String(err)}`);
444
+ });
445
+ }
446
+ log?.debug?.(`[${account.accountId}] Command menu press refused (${menu.kind})`);
447
+ return;
448
+ }
449
+ }
450
+
273
451
  // Record session meta
274
452
  void core.channel.session
275
453
  .recordSessionMetaFromInbound({
@@ -289,19 +467,35 @@ export async function processIncomingMessage(
289
467
  accountId: route.accountId,
290
468
  });
291
469
 
292
- // Send typing indicator while agent processes
293
- if (chatId != null) {
294
- opts.api.sendAction(chatId, 'typing_on').catch((err) => {
295
- log?.debug?.(`[${account.accountId}] typing_on failed: ${String(err)}`);
296
- });
297
- }
298
-
299
- // Streaming modes: "partial" = edit single message, "block" = each block as separate message
300
- const streamMode = account.config.streamMode ?? 'off';
470
+ // Typing indicator for the whole turn: core starts it per typingMode and
471
+ // stops the keepalive when the run completes, fails or is aborted.
472
+ const typingCallbacks =
473
+ chatId != null
474
+ ? createMaxTypingCallbacks({
475
+ api: opts.api,
476
+ chatId,
477
+ onError: (err) => {
478
+ log?.debug?.(`[${account.accountId}] typing_on failed: ${String(err)}`);
479
+ },
480
+ })
481
+ : undefined;
482
+
483
+ // Streaming modes: "partial" = edit single message, "block" = each block as
484
+ // separate message, "progress" = one turn-status message (progress-draft.ts).
485
+ // Core's streaming.mode wins over the older streamMode; default off.
486
+ const streamMode = resolveChannelPreviewStreamMode(
487
+ account.config as Parameters<typeof resolveChannelPreviewStreamMode>[0],
488
+ account.config.streamMode ?? 'off',
489
+ );
301
490
  const useEditStreaming = streamMode === 'partial';
302
491
  const useBlockStreaming = streamMode === 'block';
303
- const replyMid = isCallbackCommand ? undefined : messageId.replace(/_edited_\d+$/, '');
304
- const callbackId = isCallbackCommand ? messageId : undefined;
492
+
493
+ // Local reply media is read only under the agent's scoped media roots: core
494
+ // persists reply files into its media store, and the agent's own workspace
495
+ // is covered too.
496
+ const replyLocalMedia = {
497
+ mediaLocalRoots: getAgentScopedMediaLocalRoots(config, route.agentId),
498
+ };
305
499
 
306
500
  // Draft stream for edit-streaming (like Telegram's partial reply approach)
307
501
  const draft = createMaxDraftStream({
@@ -312,69 +506,82 @@ export async function processIncomingMessage(
312
506
  statusSink,
313
507
  });
314
508
 
315
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
316
- ctx: ctxPayload,
317
- cfg: config,
318
- dispatcherOptions: {
319
- ...prefixOptions,
320
- deliver: async (rawPayload) => {
321
- // This funnel consumes ReplyPayload directly, so it must apply the same
322
- // presentation fallback/render policy as core's outbound path.
323
- const payload = await materializeMaxPresentation(rawPayload);
324
- if (useEditStreaming && draft.messageId && payload.text) {
325
- await draft.finalize(payload.text, readMaxChannelButtons(payload.channelData));
326
-
327
- // Handle media if present (buttons already sit on the draft)
328
- if (payload.mediaUrls?.length || payload.mediaUrl) {
329
- await deliverMaxReply({
330
- payload: {
331
- ...payload,
332
- text: undefined,
333
- channelData: withoutMaxButtons(payload.channelData),
334
- },
509
+ // Turn status: one message edited while the agent works, deleted once the
510
+ // final answer landed; its failures never fail the turn.
511
+ const progress =
512
+ streamMode === 'progress'
513
+ ? createMaxProgressDraft({ account, chatId: chatIdStr, replyToId: replyMid, log, statusSink })
514
+ : undefined;
515
+
516
+ // One press, one answer: later payloads of the turn go to the chat.
517
+ const callbackState = { answered: false };
518
+ let dispatchFailed = false;
519
+ try {
520
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
521
+ ctx: ctxPayload,
522
+ cfg: config,
523
+ dispatcherOptions: {
524
+ ...prefixOptions,
525
+ typingCallbacks,
526
+ deliver: async (rawPayload, info) => {
527
+ // This funnel consumes ReplyPayload directly, so it must apply the same
528
+ // presentation fallback/render policy as core's outbound path.
529
+ const payload = await materializeMaxPresentation(rawPayload);
530
+ // With a live stream draft the first text chunk replaces it; longer
531
+ // answers continue as new messages, the keyboard on the last chunk.
532
+ const send = () =>
533
+ deliverMaxReply({
534
+ payload,
335
535
  account,
336
536
  chatId: chatIdStr,
337
537
  replyToId: replyMid,
338
538
  callbackId,
539
+ callbackState,
339
540
  config,
340
541
  log,
341
542
  statusSink,
543
+ // Tool/block payloads mid-turn go as their own messages; the
544
+ // draft keeps streaming until the final answer replaces it.
545
+ draft: useEditStreaming && info.kind === 'final' ? draft : undefined,
546
+ localMedia: replyLocalMedia,
547
+ kind: info.kind,
342
548
  });
549
+ if (progress && info.kind === 'final') {
550
+ await progress.deliverFinal({ isError: payload.isError === true, send });
551
+ } else {
552
+ await send();
343
553
  }
344
- return;
345
- }
346
-
347
- // Non-streaming path or no draft yet
348
- await deliverMaxReply({
349
- payload,
350
- account,
351
- chatId: chatIdStr,
352
- replyToId: replyMid,
353
- callbackId,
354
- config,
355
- log,
356
- statusSink,
357
- });
554
+ },
555
+ onError: (err, info) => {
556
+ log?.error(`[${account.accountId}] MAX ${info.kind} reply failed: ${String(err)}`);
557
+ },
358
558
  },
359
- onError: (err, info) => {
360
- log?.error(`[${account.accountId}] MAX ${info.kind} reply failed: ${String(err)}`);
559
+ replyOptions: {
560
+ onModelSelected,
561
+ ...(useEditStreaming
562
+ ? {
563
+ onPartialReply: (payload: { text?: string }) => {
564
+ if (payload.text) draft.update(payload.text);
565
+ },
566
+ }
567
+ : {}),
568
+ ...(useBlockStreaming ? { disableBlockStreaming: false } : {}),
569
+ ...(groupEntry?.skills ? { skillFilter: groupEntry.skills } : {}),
570
+ ...(progress ? progress.replyOptions : {}),
571
+ ...maxTurnAdoptionReplyOptions(),
361
572
  },
362
- },
363
- replyOptions: {
364
- onModelSelected,
365
- ...(useEditStreaming
366
- ? {
367
- onPartialReply: (payload: { text?: string }) => {
368
- if (payload.text) draft.update(payload.text);
369
- },
370
- }
371
- : {}),
372
- ...(useBlockStreaming ? { disableBlockStreaming: false } : {}),
373
- },
374
- });
375
-
376
- // Cleanup draft stream
377
- await draft.clear();
573
+ });
574
+ } catch (err) {
575
+ dispatchFailed = true;
576
+ throw err;
577
+ } finally {
578
+ await progress?.close({ failed: dispatchFailed });
579
+ // A partial draft no final answer replaced (NO_REPLY, an aborted turn, an
580
+ // answer sent by the message tool) is not the answer: remove it. After a
581
+ // failed dispatch it stays as the record of the turn.
582
+ if (!dispatchFailed && draft.messageId && !draft.finalized) await draft.discard();
583
+ else await draft.clear();
584
+ }
378
585
  }
379
586
 
380
587
  /**
@@ -400,6 +607,112 @@ export function isBotMentionedInMarkup(
400
607
  });
401
608
  }
402
609
 
610
+ interface MaxForwardedContent {
611
+ text: string;
612
+ attachments: MaxAttachment[];
613
+ /** Original author; null/undefined for channel posts or hidden senders. */
614
+ sender?: MaxUser | null;
615
+ /** Chat where the message was originally posted. */
616
+ chatId?: number;
617
+ }
618
+
619
+ /** Content of a forwarded message (link.type 'forward'), if any. */
620
+ function readForwardedMessage(
621
+ link: MaxLinkedMessage | null | undefined,
622
+ ): MaxForwardedContent | undefined {
623
+ if (link?.type !== 'forward' || !link.message) return undefined;
624
+ return {
625
+ text: link.message.text ?? '',
626
+ attachments: link.message.attachments ?? [],
627
+ sender: link.sender,
628
+ chatId: link.chat_id,
629
+ };
630
+ }
631
+
632
+ function hasAgentAttachments(attachments: MaxAttachment[]): boolean {
633
+ return attachments.some((att) => att.type !== 'inline_keyboard');
634
+ }
635
+
636
+ /** Author label of a linked message: sender name, or the origin chat for channel posts. */
637
+ function formatLinkedAuthor(
638
+ sender: MaxUser | null | undefined,
639
+ chatId: number | undefined,
640
+ ): string | undefined {
641
+ if (sender) return formatSenderName(sender);
642
+ return chatId != null ? `chat:${chatId}` : undefined;
643
+ }
644
+
645
+ /**
646
+ * The forwarded message as a marked block for the agent: header with the
647
+ * author, then its text and attachment descriptions. Empty when nothing
648
+ * usable is left (no text, descriptions or downloaded media).
649
+ */
650
+ function formatForwardBlock(
651
+ forward: MaxForwardedContent,
652
+ descriptions: string[],
653
+ hasMedia: boolean,
654
+ ): string {
655
+ const text = forward.text.trim();
656
+ const attachmentText = descriptions.join(' ');
657
+ if (!text && !attachmentText && !hasMedia) return '';
658
+ const author = formatLinkedAuthor(forward.sender, forward.chatId);
659
+ const header = author ? `[Forwarded message from ${author}]` : '[Forwarded message]';
660
+ return [header, text, attachmentText].filter(Boolean).join('\n');
661
+ }
662
+
663
+ /**
664
+ * Reply quote and forward origin as SDK supplemental facts; core renders
665
+ * them as untrusted "Reply target" / "Forwarded message context" blocks.
666
+ * @internal exported for testing.
667
+ */
668
+ export function buildSupplementalContext(
669
+ reply: MaxLinkedMessage | undefined,
670
+ replyToId: string | undefined,
671
+ forward: MaxForwardedContent | undefined,
672
+ ): { quote?: Record<string, string>; forwarded?: Record<string, string> } | undefined {
673
+ const quote = reply ? buildReplyQuote(reply, replyToId) : undefined;
674
+ const from = forward ? formatLinkedAuthor(forward.sender, forward.chatId) : undefined;
675
+ const forwarded =
676
+ forward && from
677
+ ? {
678
+ from,
679
+ fromType: forward.sender ? 'user' : 'channel',
680
+ ...(forward.sender
681
+ ? { fromId: String(forward.sender.user_id) }
682
+ : forward.chatId != null
683
+ ? { fromId: String(forward.chatId) }
684
+ : {}),
685
+ }
686
+ : undefined;
687
+ if (!quote && !forwarded) return undefined;
688
+ return { ...(quote ? { quote } : {}), ...(forwarded ? { forwarded } : {}) };
689
+ }
690
+
691
+ /** Bounded quote of the replied-to message: its text, else its attachment types. */
692
+ function buildReplyQuote(
693
+ reply: MaxLinkedMessage,
694
+ replyToId: string | undefined,
695
+ ): Record<string, string> | undefined {
696
+ const text = (reply.message?.text ?? '').trim();
697
+ const attachmentTypes = (reply.message?.attachments ?? [])
698
+ .filter((att) => att.type !== 'inline_keyboard')
699
+ .map((att) => `[${att.type ?? 'attachment'}]`)
700
+ .join(' ');
701
+ // Clip by code points so an emoji is never cut in half.
702
+ const quoted = Array.from(text || attachmentTypes);
703
+ const body =
704
+ quoted.length > MAX_REPLY_QUOTE_CHARS
705
+ ? `${quoted.slice(0, MAX_REPLY_QUOTE_CHARS - 1).join('')}…`
706
+ : quoted.join('');
707
+ const sender = formatLinkedAuthor(reply.sender, reply.chat_id);
708
+ if (!body && !sender && !replyToId) return undefined;
709
+ return {
710
+ ...(replyToId ? { id: replyToId, fullId: replyToId } : {}),
711
+ ...(body ? { body } : {}),
712
+ ...(sender ? { sender } : {}),
713
+ };
714
+ }
715
+
403
716
  function escapeRegExp(value: string): string {
404
717
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
405
718
  }