@aspalagin/openclaw-max 0.7.2
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 +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound pipeline: DM/group gates, agent routing and context, then the
|
|
3
|
+
* reply dispatch (finalizeInboundContext → dispatchReplyWithBufferedBlockDispatcher).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { toInboundMediaFacts } from 'openclaw/plugin-sdk/channel-inbound';
|
|
7
|
+
import { createReplyPrefixOptions } from 'openclaw/plugin-sdk/channel-outbound';
|
|
8
|
+
|
|
9
|
+
import type { MaxMessage, MaxUser } from './api.js';
|
|
10
|
+
import { deliverMaxReply, withoutMaxButtons } from './deliver.js';
|
|
11
|
+
import { collectInboundAttachments } from './inbound-attachments.js';
|
|
12
|
+
import type { MaxMonitorOptions } from './monitor-types.js';
|
|
13
|
+
import { materializeMaxPresentation } from './presentation.js';
|
|
14
|
+
import { getMaxRuntime } from './runtime.js';
|
|
15
|
+
import { readMaxChannelButtons, sendMaxMessage } from './send.js';
|
|
16
|
+
import { createMaxDraftStream } from './stream-draft.js';
|
|
17
|
+
import type { MaxMarkupElement } from './types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Process incoming MAX message through OpenClaw pipeline (also the target of
|
|
21
|
+
* synthesized /start and button-press messages).
|
|
22
|
+
*/
|
|
23
|
+
export async function processIncomingMessage(
|
|
24
|
+
message: MaxMessage,
|
|
25
|
+
userLocale: string | null | undefined,
|
|
26
|
+
opts: MaxMonitorOptions,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { account, config, log, statusSink } = opts;
|
|
29
|
+
const core = getMaxRuntime();
|
|
30
|
+
|
|
31
|
+
const senderId = message.sender?.user_id;
|
|
32
|
+
const senderName = formatSenderName(message.sender);
|
|
33
|
+
const senderUsername = message.sender?.username ?? undefined;
|
|
34
|
+
|
|
35
|
+
// Determine chat type and IDs
|
|
36
|
+
const chatId = message.recipient.chat_id;
|
|
37
|
+
const chatType = message.recipient.chat_type; // "dialog", "chat", "channel"
|
|
38
|
+
const isGroup = chatType === 'chat' || chatType === 'channel';
|
|
39
|
+
|
|
40
|
+
const rawText = message.body.text ?? '';
|
|
41
|
+
const messageId = message.body.mid;
|
|
42
|
+
const isCallbackCommand =
|
|
43
|
+
(message as MaxMessage & { __maxCallback?: boolean }).__maxCallback === true;
|
|
44
|
+
const attachments = message.body.attachments ?? [];
|
|
45
|
+
|
|
46
|
+
log?.debug?.(
|
|
47
|
+
`[${account.accountId}] Processing message: mid=${messageId} chatId=${message.recipient.chat_id} chatType=${message.recipient.chat_type} senderId=${message.sender?.user_id} text="${rawText.slice(0, 50)}" attachments=${attachments.length}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Skip truly empty messages (no text and no attachment that yields media or
|
|
51
|
+
// a description; inline keyboards yield neither). Attachments are
|
|
52
|
+
// downloaded only after the DM/group gates below, so ignored group chatter
|
|
53
|
+
// costs no traffic or disk.
|
|
54
|
+
if (!rawText.trim() && !attachments.some((att) => att.type !== 'inline_keyboard')) return;
|
|
55
|
+
|
|
56
|
+
// Check for reply context
|
|
57
|
+
const replyToId = message.link?.type === 'reply' ? message.link.message?.body?.mid : undefined;
|
|
58
|
+
|
|
59
|
+
// Check for bot mention in group chats
|
|
60
|
+
let wasMentioned: boolean | undefined;
|
|
61
|
+
if (isGroup && (opts.botUsername || opts.botUserId)) {
|
|
62
|
+
// body.markup marks mentions as user_mention elements; the @botname regex
|
|
63
|
+
// stays as a fallback for clients/messages that send no markup.
|
|
64
|
+
wasMentioned = isBotMentionedInMarkup(message.body?.markup, opts.botUserId, opts.botUsername);
|
|
65
|
+
if (!wasMentioned && opts.botUsername) {
|
|
66
|
+
const mentionPattern = new RegExp(`@${escapeRegExp(opts.botUsername)}\\b`, 'i');
|
|
67
|
+
wasMentioned = mentionPattern.test(rawText);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Reply to bot's message also counts as mention (like Telegram behavior)
|
|
71
|
+
if (!wasMentioned && message.link?.type === 'reply') {
|
|
72
|
+
const replySender = message.link.sender;
|
|
73
|
+
if (replySender?.is_bot && replySender?.user_id === opts.botUserId) {
|
|
74
|
+
wasMentioned = true;
|
|
75
|
+
log?.debug?.(`[${account.accountId}] Reply to bot message treated as mention`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pressing a button on the bot's own keyboard is addressed to the bot, like a
|
|
81
|
+
// reply to its message — don't drop it at the group mention gate.
|
|
82
|
+
if (isGroup && isCallbackCommand) {
|
|
83
|
+
wasMentioned = true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// DM security: check pairing/allowlist
|
|
87
|
+
if (!isGroup) {
|
|
88
|
+
const dmPolicy = account.config.dmPolicy ?? 'pairing';
|
|
89
|
+
if (dmPolicy === 'disabled') {
|
|
90
|
+
log?.debug?.(`[${account.accountId}] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (dmPolicy !== 'open') {
|
|
95
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map(String);
|
|
96
|
+
const storeAllowFrom = await core.channel.pairing
|
|
97
|
+
.readAllowFromStore({ channel: 'max', accountId: account.accountId })
|
|
98
|
+
.catch(() => []);
|
|
99
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
100
|
+
|
|
101
|
+
const senderStr = String(senderId);
|
|
102
|
+
const allowed = effectiveAllowFrom.includes(senderStr) || effectiveAllowFrom.includes('*');
|
|
103
|
+
|
|
104
|
+
if (!allowed) {
|
|
105
|
+
if (dmPolicy === 'pairing') {
|
|
106
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
107
|
+
channel: 'max',
|
|
108
|
+
id: senderStr,
|
|
109
|
+
accountId: account.accountId,
|
|
110
|
+
meta: { name: senderName },
|
|
111
|
+
});
|
|
112
|
+
if (created) {
|
|
113
|
+
log?.info(`[${account.accountId}] Pairing request from ${senderStr}`);
|
|
114
|
+
try {
|
|
115
|
+
const pairingReply = core.channel.pairing.buildPairingReply({
|
|
116
|
+
channel: 'max',
|
|
117
|
+
idLine: `Your MAX user id: ${senderStr}`,
|
|
118
|
+
code,
|
|
119
|
+
});
|
|
120
|
+
await sendMaxMessage(String(chatId ?? senderId), pairingReply, {
|
|
121
|
+
token: account.token,
|
|
122
|
+
});
|
|
123
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log?.error(`[${account.accountId}] Pairing reply failed: ${String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Group policy
|
|
135
|
+
if (isGroup) {
|
|
136
|
+
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
|
137
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
|
138
|
+
|
|
139
|
+
if (groupPolicy === 'disabled') {
|
|
140
|
+
log?.debug?.(`[${account.accountId}] Blocked group message (groupPolicy=disabled)`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// For allowlist policy, check if chat is in the groups config
|
|
145
|
+
if (groupPolicy === 'allowlist') {
|
|
146
|
+
const groups = account.config.groups ?? {};
|
|
147
|
+
const chatIdStr = String(chatId);
|
|
148
|
+
const hasWildcard = '*' in groups;
|
|
149
|
+
const chatAllowed = chatIdStr in groups || hasWildcard;
|
|
150
|
+
if (!chatAllowed) {
|
|
151
|
+
log?.debug?.(
|
|
152
|
+
`[${account.accountId}] Blocked group message (not in allowlist, chat=${chatIdStr})`,
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Require mention in groups
|
|
159
|
+
const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
|
|
160
|
+
const requireMention = groupCfg?.requireMention ?? true;
|
|
161
|
+
if (requireMention && !wasMentioned) {
|
|
162
|
+
log?.debug?.(`[${account.accountId}] Skipping group message (not mentioned)`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Process attachments: download media, build descriptions for non-downloadable types
|
|
168
|
+
const { descriptions: attachmentDescriptions, mediaInputs } = await collectInboundAttachments({
|
|
169
|
+
attachments,
|
|
170
|
+
messageId,
|
|
171
|
+
chatId,
|
|
172
|
+
api: opts.api,
|
|
173
|
+
account,
|
|
174
|
+
log,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const attachmentText = attachmentDescriptions.join(' ');
|
|
178
|
+
const hasMedia = mediaInputs.length > 0;
|
|
179
|
+
const effectiveText = rawText.trim() || attachmentText;
|
|
180
|
+
|
|
181
|
+
// Nothing usable came out of the attachments (e.g. a failed sticker download)
|
|
182
|
+
if (!effectiveText && !hasMedia) return;
|
|
183
|
+
|
|
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
|
+
// Build context
|
|
202
|
+
const fromLabel = isGroup ? `chat:${chatIdStr}` : senderName || `user:${senderId}`;
|
|
203
|
+
|
|
204
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
205
|
+
agentId: route.agentId,
|
|
206
|
+
});
|
|
207
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
208
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
209
|
+
storePath,
|
|
210
|
+
sessionKey: route.sessionKey,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Combine text and attachment descriptions for the agent
|
|
214
|
+
const bodyForAgent = attachmentText
|
|
215
|
+
? rawText.trim()
|
|
216
|
+
? `${rawText.trim()}\n${attachmentText}`
|
|
217
|
+
: attachmentText
|
|
218
|
+
: rawText;
|
|
219
|
+
|
|
220
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
221
|
+
channel: 'MAX',
|
|
222
|
+
from: fromLabel,
|
|
223
|
+
timestamp: message.timestamp,
|
|
224
|
+
previousTimestamp,
|
|
225
|
+
envelope: envelopeOptions,
|
|
226
|
+
body: bodyForAgent,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Detect text-slash commands (user types /status, /models, /reasoning etc.)
|
|
230
|
+
const rawTextTrimmed = (rawText || '').trim();
|
|
231
|
+
const isTextSlashCommand = rawTextTrimmed.startsWith('/');
|
|
232
|
+
|
|
233
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
234
|
+
Body: body,
|
|
235
|
+
BodyForAgent: bodyForAgent,
|
|
236
|
+
RawBody: rawText,
|
|
237
|
+
CommandBody: rawText || attachmentText,
|
|
238
|
+
From: `max:${senderId}`,
|
|
239
|
+
To: `max:${chatIdStr}`,
|
|
240
|
+
SessionKey: route.sessionKey,
|
|
241
|
+
AccountId: route.accountId,
|
|
242
|
+
ChatType: isGroup ? 'group' : 'direct',
|
|
243
|
+
ConversationLabel: fromLabel,
|
|
244
|
+
SenderName: senderName || undefined,
|
|
245
|
+
SenderId: senderId != null ? String(senderId) : undefined,
|
|
246
|
+
SenderUsername: senderUsername,
|
|
247
|
+
WasMentioned: isGroup ? wasMentioned : undefined,
|
|
248
|
+
Provider: 'max',
|
|
249
|
+
Surface: 'max',
|
|
250
|
+
MessageSid: messageId,
|
|
251
|
+
MessageSidFull: messageId,
|
|
252
|
+
ReplyToId: replyToId,
|
|
253
|
+
ReplyToIdFull: replyToId,
|
|
254
|
+
OriginatingChannel: 'max',
|
|
255
|
+
OriginatingTo: `max:${chatIdStr}`,
|
|
256
|
+
// Media attachments (downloaded to local paths) as ordered media facts
|
|
257
|
+
media: hasMedia ? toInboundMediaFacts(mediaInputs) : undefined,
|
|
258
|
+
// Text-slash command detection: treat /status, /models etc. as text commands
|
|
259
|
+
// so OpenClaw routes them through handleCommands instead of silently dropping
|
|
260
|
+
...(isTextSlashCommand
|
|
261
|
+
? {
|
|
262
|
+
CommandSource: 'text' as const,
|
|
263
|
+
CommandTurn: {
|
|
264
|
+
kind: 'text-slash' as const,
|
|
265
|
+
source: 'text' as const,
|
|
266
|
+
authorized: undefined, // let allowFrom resolve authorization
|
|
267
|
+
body: rawTextTrimmed,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Record session meta
|
|
274
|
+
void core.channel.session
|
|
275
|
+
.recordSessionMetaFromInbound({
|
|
276
|
+
storePath,
|
|
277
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
278
|
+
ctx: ctxPayload,
|
|
279
|
+
})
|
|
280
|
+
.catch((err) => {
|
|
281
|
+
log?.error(`[${account.accountId}] Failed updating session meta: ${String(err)}`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Dispatch through the standard reply pipeline
|
|
285
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
286
|
+
cfg: config,
|
|
287
|
+
agentId: route.agentId,
|
|
288
|
+
channel: 'max',
|
|
289
|
+
accountId: route.accountId,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Send typing indicator while agent processes
|
|
293
|
+
if (chatId != null) {
|
|
294
|
+
opts.api.sendAction(chatId, 'typing_on').catch((err) => {
|
|
295
|
+
log?.debug?.(`[${account.accountId}] typing_on failed: ${String(err)}`);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Streaming modes: "partial" = edit single message, "block" = each block as separate message
|
|
300
|
+
const streamMode = account.config.streamMode ?? 'off';
|
|
301
|
+
const useEditStreaming = streamMode === 'partial';
|
|
302
|
+
const useBlockStreaming = streamMode === 'block';
|
|
303
|
+
const replyMid = isCallbackCommand ? undefined : messageId.replace(/_edited_\d+$/, '');
|
|
304
|
+
const callbackId = isCallbackCommand ? messageId : undefined;
|
|
305
|
+
|
|
306
|
+
// Draft stream for edit-streaming (like Telegram's partial reply approach)
|
|
307
|
+
const draft = createMaxDraftStream({
|
|
308
|
+
account,
|
|
309
|
+
chatId: chatIdStr,
|
|
310
|
+
replyToId: replyMid,
|
|
311
|
+
log,
|
|
312
|
+
statusSink,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
316
|
+
ctx: ctxPayload,
|
|
317
|
+
cfg: config,
|
|
318
|
+
dispatcherOptions: {
|
|
319
|
+
...prefixOptions,
|
|
320
|
+
deliver: async (rawPayload) => {
|
|
321
|
+
// This funnel consumes ReplyPayload directly, so it must apply the same
|
|
322
|
+
// presentation fallback/render policy as core's outbound path.
|
|
323
|
+
const payload = await materializeMaxPresentation(rawPayload);
|
|
324
|
+
if (useEditStreaming && draft.messageId && payload.text) {
|
|
325
|
+
await draft.finalize(payload.text, readMaxChannelButtons(payload.channelData));
|
|
326
|
+
|
|
327
|
+
// Handle media if present (buttons already sit on the draft)
|
|
328
|
+
if (payload.mediaUrls?.length || payload.mediaUrl) {
|
|
329
|
+
await deliverMaxReply({
|
|
330
|
+
payload: {
|
|
331
|
+
...payload,
|
|
332
|
+
text: undefined,
|
|
333
|
+
channelData: withoutMaxButtons(payload.channelData),
|
|
334
|
+
},
|
|
335
|
+
account,
|
|
336
|
+
chatId: chatIdStr,
|
|
337
|
+
replyToId: replyMid,
|
|
338
|
+
callbackId,
|
|
339
|
+
config,
|
|
340
|
+
log,
|
|
341
|
+
statusSink,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Non-streaming path or no draft yet
|
|
348
|
+
await deliverMaxReply({
|
|
349
|
+
payload,
|
|
350
|
+
account,
|
|
351
|
+
chatId: chatIdStr,
|
|
352
|
+
replyToId: replyMid,
|
|
353
|
+
callbackId,
|
|
354
|
+
config,
|
|
355
|
+
log,
|
|
356
|
+
statusSink,
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
onError: (err, info) => {
|
|
360
|
+
log?.error(`[${account.accountId}] MAX ${info.kind} reply failed: ${String(err)}`);
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
replyOptions: {
|
|
364
|
+
onModelSelected,
|
|
365
|
+
...(useEditStreaming
|
|
366
|
+
? {
|
|
367
|
+
onPartialReply: (payload: { text?: string }) => {
|
|
368
|
+
if (payload.text) draft.update(payload.text);
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
: {}),
|
|
372
|
+
...(useBlockStreaming ? { disableBlockStreaming: false } : {}),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Cleanup draft stream
|
|
377
|
+
await draft.clear();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whether body.markup has a user_mention of this bot: by user_id (users
|
|
382
|
+
* without a username) or by user_link `@username` (case-insensitive).
|
|
383
|
+
* @internal exported for testing.
|
|
384
|
+
*/
|
|
385
|
+
export function isBotMentionedInMarkup(
|
|
386
|
+
markup: MaxMarkupElement[] | null | undefined,
|
|
387
|
+
botUserId?: number,
|
|
388
|
+
botUsername?: string,
|
|
389
|
+
): boolean {
|
|
390
|
+
if (!Array.isArray(markup)) return false;
|
|
391
|
+
const username = botUsername?.replace(/^@/, '').toLowerCase();
|
|
392
|
+
return markup.some((element) => {
|
|
393
|
+
if (element?.type !== 'user_mention') return false;
|
|
394
|
+
if (botUserId != null && element.user_id === botUserId) return true;
|
|
395
|
+
const link =
|
|
396
|
+
typeof element.user_link === 'string'
|
|
397
|
+
? element.user_link.replace(/^@/, '').toLowerCase()
|
|
398
|
+
: '';
|
|
399
|
+
return Boolean(username && link === username);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function escapeRegExp(value: string): string {
|
|
404
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function formatSenderName(user?: MaxUser | null): string {
|
|
408
|
+
if (!user) return 'Unknown';
|
|
409
|
+
const parts = [user.first_name];
|
|
410
|
+
if (user.last_name) parts.push(user.last_name);
|
|
411
|
+
return parts.join(' ') || user.username || `user_${user.user_id}`;
|
|
412
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote media → temporary local file, for MAX uploads that need a path.
|
|
3
|
+
* One place for the download, the file name policy and the cleanup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { getMaxRuntime } from './runtime.js';
|
|
11
|
+
|
|
12
|
+
/** Upper bound for a sanitized file name (bytes of UTF-8 stay well under 255). */
|
|
13
|
+
export const MAX_TEMP_FILE_NAME_LENGTH = 120;
|
|
14
|
+
|
|
15
|
+
const EXTENSION_BY_CONTENT_TYPE: Record<string, string> = {
|
|
16
|
+
'image/jpeg': 'jpg',
|
|
17
|
+
'image/png': 'png',
|
|
18
|
+
'image/gif': 'gif',
|
|
19
|
+
'image/webp': 'webp',
|
|
20
|
+
'image/heic': 'heic',
|
|
21
|
+
'image/tiff': 'tiff',
|
|
22
|
+
'image/bmp': 'bmp',
|
|
23
|
+
'video/mp4': 'mp4',
|
|
24
|
+
'video/quicktime': 'mov',
|
|
25
|
+
'video/x-matroska': 'mkv',
|
|
26
|
+
'video/webm': 'webm',
|
|
27
|
+
'audio/mpeg': 'mp3',
|
|
28
|
+
'audio/ogg': 'ogg',
|
|
29
|
+
'audio/mp4': 'm4a',
|
|
30
|
+
'audio/wav': 'wav',
|
|
31
|
+
'audio/aac': 'aac',
|
|
32
|
+
'audio/flac': 'flac',
|
|
33
|
+
'audio/opus': 'opus',
|
|
34
|
+
'application/pdf': 'pdf',
|
|
35
|
+
'text/plain': 'txt',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A remote file name reduced to one safe path segment: no directories, no
|
|
40
|
+
* `..`, no control or path-reserved characters, bounded length, extension
|
|
41
|
+
* kept. Falls back to `file` (plus an extension from contentType if known).
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeMaxFileName(name: string | undefined, contentType?: string): string {
|
|
44
|
+
let base = (name ?? '').normalize('NFC');
|
|
45
|
+
// Last path segment only (both separators; URL-encoded ones too).
|
|
46
|
+
base =
|
|
47
|
+
base
|
|
48
|
+
.replace(/%2f|%5c/gi, '/')
|
|
49
|
+
.split(/[/\\]/)
|
|
50
|
+
.pop() ?? '';
|
|
51
|
+
base = base
|
|
52
|
+
.replace(/[\u0000-\u001f\u007f-\u009f<>:"|?*]/g, '_')
|
|
53
|
+
.replace(/\s+/g, ' ')
|
|
54
|
+
.replace(/\.{2,}/g, '.')
|
|
55
|
+
.replace(/^[.\s]+|[.\s]+$/g, '');
|
|
56
|
+
|
|
57
|
+
const dot = base.lastIndexOf('.');
|
|
58
|
+
let stem = dot > 0 ? base.slice(0, dot) : base;
|
|
59
|
+
let ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
|
|
60
|
+
if (!/^[a-z0-9]{1,10}$/.test(ext)) {
|
|
61
|
+
if (ext) stem = base;
|
|
62
|
+
ext = '';
|
|
63
|
+
}
|
|
64
|
+
if (!ext && contentType)
|
|
65
|
+
ext = EXTENSION_BY_CONTENT_TYPE[contentType.split(';')[0].trim().toLowerCase()] ?? '';
|
|
66
|
+
|
|
67
|
+
if (!stem) stem = 'file';
|
|
68
|
+
const room = MAX_TEMP_FILE_NAME_LENGTH - (ext ? ext.length + 1 : 0);
|
|
69
|
+
stem = Array.from(stem).slice(0, room).join('').trim() || 'file';
|
|
70
|
+
return ext ? `${stem}.${ext}` : stem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type MaxTempMedia = { path: string; contentType?: string; fileName: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Download `url` through the runtime media fetcher (size-capped), write it to
|
|
77
|
+
* a private temp directory under a sanitized name, run `use`, then remove the
|
|
78
|
+
* directory whatever happens.
|
|
79
|
+
*/
|
|
80
|
+
export async function withRemoteMediaTempFile<T>(
|
|
81
|
+
url: string,
|
|
82
|
+
maxBytes: number,
|
|
83
|
+
use: (media: MaxTempMedia) => Promise<T>,
|
|
84
|
+
): Promise<T> {
|
|
85
|
+
const loaded = await getMaxRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
|
|
86
|
+
const fileName = sanitizeMaxFileName(loaded.fileName ?? fileNameFromUrl(url), loaded.contentType);
|
|
87
|
+
const dir = await mkdtemp(join(tmpdir(), 'max-media-'));
|
|
88
|
+
try {
|
|
89
|
+
const path = join(dir, fileName);
|
|
90
|
+
await writeFile(path, loaded.buffer, { mode: 0o600 });
|
|
91
|
+
return await use({ path, contentType: loaded.contentType, fileName });
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fileNameFromUrl(url: string): string | undefined {
|
|
98
|
+
try {
|
|
99
|
+
return decodeURIComponent(new URL(url).pathname.split('/').pop() ?? '') || undefined;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
type MaxButton = {
|
|
2
|
+
text: string;
|
|
3
|
+
payload?: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type ProviderInfo = {
|
|
8
|
+
id: string;
|
|
9
|
+
count: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const MODELS_PAGE_SIZE = 8;
|
|
13
|
+
|
|
14
|
+
function chunkRows<T>(items: T[], rowSize: number): T[][] {
|
|
15
|
+
const rows: T[][] = [];
|
|
16
|
+
for (let index = 0; index < items.length; index += rowSize) {
|
|
17
|
+
rows.push(items.slice(index, index + rowSize));
|
|
18
|
+
}
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function truncateModelId(modelId: string, maxLen: number): string {
|
|
23
|
+
if (modelId.length <= maxLen) return modelId;
|
|
24
|
+
return `…${modelId.slice(-(maxLen - 1))}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isCurrentModelSelection(params: {
|
|
28
|
+
currentModel?: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
model: string;
|
|
31
|
+
}): boolean {
|
|
32
|
+
const currentModel = params.currentModel?.trim();
|
|
33
|
+
if (!currentModel) return false;
|
|
34
|
+
return currentModel.includes('/')
|
|
35
|
+
? currentModel === `${params.provider}/${params.model}`
|
|
36
|
+
: currentModel === params.model;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function maxChannelData(buttons: MaxButton[][]) {
|
|
40
|
+
return buttons.length > 0 ? { max: { buttons } } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildMaxProviderKeyboard(providers: ProviderInfo[]): MaxButton[][] {
|
|
44
|
+
return chunkRows(
|
|
45
|
+
providers.map((provider) => ({
|
|
46
|
+
text: `${provider.id} (${provider.count})`,
|
|
47
|
+
payload: `/models ${provider.id}`,
|
|
48
|
+
})),
|
|
49
|
+
2,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildMaxModelsKeyboard(params: {
|
|
54
|
+
provider: string;
|
|
55
|
+
models: readonly string[];
|
|
56
|
+
currentModel?: string;
|
|
57
|
+
currentPage: number;
|
|
58
|
+
totalPages: number;
|
|
59
|
+
pageSize?: number;
|
|
60
|
+
modelNames?: ReadonlyMap<string, string>;
|
|
61
|
+
}): MaxButton[][] {
|
|
62
|
+
const pageSize = params.pageSize ?? MODELS_PAGE_SIZE;
|
|
63
|
+
if (params.models.length === 0) {
|
|
64
|
+
return [[{ text: '← Назад', payload: '/models' }]];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rows: MaxButton[][] = [];
|
|
68
|
+
const startIndex = (params.currentPage - 1) * pageSize;
|
|
69
|
+
const pageModels = params.models.slice(startIndex, startIndex + pageSize);
|
|
70
|
+
|
|
71
|
+
for (const model of pageModels) {
|
|
72
|
+
const fallbackLabel = model.includes('/') ? `${params.provider}/${model}` : model;
|
|
73
|
+
const displayText = truncateModelId(
|
|
74
|
+
params.modelNames?.get(`${params.provider}/${model}`) ?? fallbackLabel,
|
|
75
|
+
38,
|
|
76
|
+
);
|
|
77
|
+
const text = isCurrentModelSelection({
|
|
78
|
+
currentModel: params.currentModel,
|
|
79
|
+
provider: params.provider,
|
|
80
|
+
model,
|
|
81
|
+
})
|
|
82
|
+
? `${displayText} ✓`
|
|
83
|
+
: displayText;
|
|
84
|
+
|
|
85
|
+
rows.push([{ text, payload: `/model ${params.provider}/${model}` }]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (params.totalPages > 1) {
|
|
89
|
+
const paginationRow: MaxButton[] = [];
|
|
90
|
+
if (params.currentPage > 1) {
|
|
91
|
+
paginationRow.push({
|
|
92
|
+
text: '◀ Назад',
|
|
93
|
+
payload: `/models ${params.provider} page=${params.currentPage - 1}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
paginationRow.push({
|
|
97
|
+
text: `${params.currentPage}/${params.totalPages}`,
|
|
98
|
+
payload: `/models ${params.provider} page=${params.currentPage}`,
|
|
99
|
+
});
|
|
100
|
+
if (params.currentPage < params.totalPages) {
|
|
101
|
+
paginationRow.push({
|
|
102
|
+
text: 'Вперёд ▶',
|
|
103
|
+
payload: `/models ${params.provider} page=${params.currentPage + 1}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
rows.push(paginationRow);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
rows.push([{ text: '← Назад', payload: '/models' }]);
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildMaxBrowseProvidersButton(): MaxButton[][] {
|
|
114
|
+
return [[{ text: 'Выбрать провайдера', payload: '/models' }]];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildMaxModelsMenuChannelData(params: { providers: ProviderInfo[] }) {
|
|
118
|
+
return maxChannelData(buildMaxProviderKeyboard(params.providers));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildMaxModelsProviderChannelData(params: { providers: ProviderInfo[] }) {
|
|
122
|
+
return maxChannelData(buildMaxProviderKeyboard(params.providers));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildMaxModelsAddProviderChannelData(params: { providers: Array<{ id: string }> }) {
|
|
126
|
+
return maxChannelData(
|
|
127
|
+
params.providers.map((provider) => [
|
|
128
|
+
{
|
|
129
|
+
text: provider.id,
|
|
130
|
+
payload: `/models add ${provider.id}`,
|
|
131
|
+
},
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildMaxModelsListChannelData(params: {
|
|
137
|
+
provider: string;
|
|
138
|
+
models: readonly string[];
|
|
139
|
+
currentModel?: string;
|
|
140
|
+
currentPage: number;
|
|
141
|
+
totalPages: number;
|
|
142
|
+
pageSize?: number;
|
|
143
|
+
modelNames?: ReadonlyMap<string, string>;
|
|
144
|
+
}) {
|
|
145
|
+
return { max: { buttons: buildMaxModelsKeyboard(params) } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function buildMaxModelBrowseChannelData() {
|
|
149
|
+
return { max: { buttons: buildMaxBrowseProvidersButton() } };
|
|
150
|
+
}
|