@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/dist/src/inbound.js
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
31
|
-
|
|
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)
|
|
34
|
-
// downloaded only after the DM/group gates below,
|
|
35
|
-
// costs no traffic or disk.
|
|
36
|
-
if (!rawText.trim() &&
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
account
|
|
304
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
}
|