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