@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/SKILL.md +108 -0
  4. package/dist/index.js +17 -0
  5. package/dist/setup-entry.js +8 -0
  6. package/dist/src/accounts.js +148 -0
  7. package/dist/src/actions.js +421 -0
  8. package/dist/src/api.js +553 -0
  9. package/dist/src/callbacks.js +111 -0
  10. package/dist/src/channel-agent-prompt.js +38 -0
  11. package/dist/src/channel-config.js +103 -0
  12. package/dist/src/channel-directory.js +84 -0
  13. package/dist/src/channel-lifecycle.js +219 -0
  14. package/dist/src/channel-outbound.js +133 -0
  15. package/dist/src/channel-policy.js +130 -0
  16. package/dist/src/channel.js +76 -0
  17. package/dist/src/config-schema.js +133 -0
  18. package/dist/src/deliver.js +126 -0
  19. package/dist/src/dispatch.js +186 -0
  20. package/dist/src/format.js +31 -0
  21. package/dist/src/inbound-attachments.js +125 -0
  22. package/dist/src/inbound.js +362 -0
  23. package/dist/src/media-temp.js +92 -0
  24. package/dist/src/model-buttons.js +96 -0
  25. package/dist/src/monitor-types.js +27 -0
  26. package/dist/src/monitor.js +40 -0
  27. package/dist/src/onboarding.js +180 -0
  28. package/dist/src/polling.js +98 -0
  29. package/dist/src/presentation.js +374 -0
  30. package/dist/src/runtime.js +36 -0
  31. package/dist/src/russian-trusted-ca.js +84 -0
  32. package/dist/src/send.js +612 -0
  33. package/dist/src/state.js +111 -0
  34. package/dist/src/sticker-cache.js +26 -0
  35. package/dist/src/stream-draft.js +102 -0
  36. package/dist/src/types.js +5 -0
  37. package/dist/src/webhook-queue.js +215 -0
  38. package/dist/src/webhook-runner.js +233 -0
  39. package/dist/src/webhook.js +196 -0
  40. package/openclaw.plugin.json +263 -0
  41. package/package.json +100 -0
  42. package/scripts/check-cycles.mjs +38 -0
  43. package/scripts/test-api.mjs +169 -0
  44. package/scripts/test-send.mjs +162 -0
  45. package/scripts/update-schema.mjs +48 -0
  46. package/src/accounts.ts +193 -0
  47. package/src/actions.ts +501 -0
  48. package/src/api.ts +758 -0
  49. package/src/callbacks.ts +140 -0
  50. package/src/channel-agent-prompt.ts +47 -0
  51. package/src/channel-config.ts +138 -0
  52. package/src/channel-directory.ts +90 -0
  53. package/src/channel-lifecycle.ts +256 -0
  54. package/src/channel-outbound.ts +159 -0
  55. package/src/channel-policy.ts +185 -0
  56. package/src/channel.ts +99 -0
  57. package/src/config-schema.ts +154 -0
  58. package/src/deliver.ts +168 -0
  59. package/src/dispatch.ts +224 -0
  60. package/src/format.ts +33 -0
  61. package/src/inbound-attachments.ts +152 -0
  62. package/src/inbound.ts +412 -0
  63. package/src/media-temp.ts +103 -0
  64. package/src/model-buttons.ts +150 -0
  65. package/src/monitor-types.ts +61 -0
  66. package/src/monitor.ts +49 -0
  67. package/src/onboarding.ts +215 -0
  68. package/src/polling.ts +125 -0
  69. package/src/presentation.ts +452 -0
  70. package/src/runtime.ts +56 -0
  71. package/src/russian-trusted-ca.ts +86 -0
  72. package/src/send.ts +774 -0
  73. package/src/state.ts +149 -0
  74. package/src/sticker-cache.ts +28 -0
  75. package/src/stream-draft.ts +125 -0
  76. package/src/types.ts +263 -0
  77. package/src/webhook-queue.ts +248 -0
  78. package/src/webhook-runner.ts +296 -0
  79. 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
+ }