@aspalagin/openclaw-max 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/src/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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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}
|
|
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)
|
|
52
|
-
// downloaded only after the DM/group gates below,
|
|
53
|
-
// costs no traffic or disk.
|
|
54
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
}
|