@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
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Конвертер форматирования OpenClaw/стандартный markdown → MAX markdown.
3
+ *
4
+ * Отличия MAX markdown от стандартного:
5
+ * - Подчёркивание: MAX не поддерживает markdown-синтаксис подчёркивания, но
6
+ * рендерит HTML-тег <u>…</u> → конвертируем в ++…++.
7
+ * - `**bold**` и `__bold__` MAX рендерит как жирный сам — НЕ трогаем.
8
+ * - Упоминание: [Имя](max://user/user_id) — синтаксис MAX, оставляем как есть.
9
+ * - bold, italic, strikethrough, code, ссылки — совпадают со стандартом.
10
+ *
11
+ * Конвертация выполняется только ВНЕ inline-кода, fenced-блоков и URL, чтобы
12
+ * не портить текст внутри `код` / ```блоков``` / ссылок.
13
+ */
14
+ const CODE_OR_URL = /(```[\s\S]*?```|`[^`]*`|https?:\/\/\S+)/g;
15
+ /** Заменяет только <u>…</u> → ++…++; markdown-подчёркивание MAX не поддерживает. */
16
+ function convertSegment(text) {
17
+ return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++');
18
+ }
19
+ /**
20
+ * Конвертирует OpenClaw/стандартный markdown в MAX markdown, не затрагивая
21
+ * содержимое inline-кода, fenced-блоков и URL.
22
+ */
23
+ export function toMaxMarkdown(text) {
24
+ if (!text)
25
+ return text;
26
+ // split с захватывающей группой: нечётные индексы — код/URL (не трогаем)
27
+ return text
28
+ .split(CODE_OR_URL)
29
+ .map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
30
+ .join('');
31
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Inbound attachments: downloads media (image, sticker, video, audio, file)
3
+ * into the gateway media store and describes the rest as text for the agent.
4
+ */
5
+ import { sanitizeMaxFileName } from './media-temp.js';
6
+ import { getMaxRuntime } from './runtime.js';
7
+ import { rememberStickerCode } from './sticker-cache.js';
8
+ /** Process attachments: download media, build descriptions for non-downloadable types. */
9
+ export async function collectInboundAttachments(params) {
10
+ const { attachments, messageId, chatId, api, account, log } = params;
11
+ const core = getMaxRuntime();
12
+ const attachmentDescriptions = [];
13
+ const mediaInputs = [];
14
+ for (const att of attachments) {
15
+ const attType = att.type ?? 'unknown';
16
+ const payload = att.payload;
17
+ // Media types with downloadable URL: image, sticker, video, audio, file
18
+ if (['image', 'sticker', 'video', 'audio', 'file'].includes(attType)) {
19
+ // For stickers, always capture the code for outbound use
20
+ const stickerCode = attType === 'sticker' ? (payload?.code ?? '') : '';
21
+ if (stickerCode) {
22
+ log?.debug?.(`[${account.accountId}] Sticker received: code=${stickerCode}`);
23
+ attachmentDescriptions.push(`[Sticker: code=${stickerCode}]`);
24
+ if (chatId != null) {
25
+ rememberStickerCode(chatId, stickerCode);
26
+ }
27
+ }
28
+ let url = (payload?.url ?? att.url ?? '');
29
+ // Inbound video attachments often carry only a token — resolve playback
30
+ // URLs via GET /videos/{videoToken} instead of degrading to "[video]".
31
+ if (!url && attType === 'video' && typeof payload?.token === 'string' && payload.token) {
32
+ try {
33
+ const info = await api.getVideoInfo(payload.token);
34
+ const urls = info?.urls ?? undefined;
35
+ url =
36
+ urls?.mp4_720 ??
37
+ urls?.mp4_480 ??
38
+ urls?.mp4_1080 ??
39
+ urls?.mp4_360 ??
40
+ urls?.mp4_240 ??
41
+ urls?.mp4_144 ??
42
+ '';
43
+ if (!url) {
44
+ log?.debug?.(`[${account.accountId}] Video ${payload.token.slice(0, 12)}… has no playback URLs yet`);
45
+ }
46
+ }
47
+ catch (err) {
48
+ log?.debug?.(`[${account.accountId}] getVideoInfo failed: ${String(err)}`);
49
+ }
50
+ }
51
+ // MAX may transcribe voice messages itself (AudioAttachment.transcription,
52
+ // a sibling of payload). The text goes to the agent and the audio fact is
53
+ // marked transcribed, so core media understanding does not run STT again.
54
+ const transcription = attType === 'audio' ? readAudioTranscription(att) : undefined;
55
+ if (transcription) {
56
+ attachmentDescriptions.push(`[Voice transcript: ${transcription}]`);
57
+ }
58
+ if (url && typeof url === 'string' && url.startsWith('http')) {
59
+ try {
60
+ const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
61
+ const fetched = await core.channel.media.fetchRemoteMedia({ url, maxBytes });
62
+ const inboundFileName = fetched.fileName
63
+ ? sanitizeMaxFileName(fetched.fileName, fetched.contentType)
64
+ : undefined;
65
+ const saved = await core.channel.media.saveMediaBuffer(Buffer.from(fetched.buffer), fetched.contentType, 'inbound', maxBytes, inboundFileName);
66
+ // Only the local copy goes to the agent: MAX download URLs are signed
67
+ // and short-lived, so they are not recorded as the media url.
68
+ mediaInputs.push({
69
+ path: saved.path,
70
+ contentType: saved.contentType,
71
+ fileName: inboundFileName,
72
+ messageId,
73
+ ...(transcription ? { transcribed: true } : {}),
74
+ });
75
+ }
76
+ catch (err) {
77
+ log?.error?.(`[${account.accountId}] Failed to download ${attType}: ${String(err)}`);
78
+ // Fall back to text description (sticker code already added above)
79
+ if (attType !== 'sticker') {
80
+ attachmentDescriptions.push(`[${attType}: ${url}]`);
81
+ }
82
+ }
83
+ }
84
+ else {
85
+ // No URL — text description
86
+ if (attType === 'sticker') {
87
+ const code = payload?.code ?? '';
88
+ attachmentDescriptions.push(`[Sticker${code ? `: ${code}` : ''}]`);
89
+ }
90
+ else if (attType === 'file') {
91
+ const filename = att.filename ?? payload?.filename ?? '';
92
+ attachmentDescriptions.push(`[File${filename ? `: ${filename}` : ''}]`);
93
+ }
94
+ else {
95
+ attachmentDescriptions.push(`[${attType}]`);
96
+ }
97
+ }
98
+ }
99
+ else if (attType === 'share') {
100
+ const url = (payload?.url ?? att.url ?? '');
101
+ attachmentDescriptions.push(`[Share${url ? `: ${url}` : ''}]`);
102
+ }
103
+ else if (attType === 'location') {
104
+ const lat = att.latitude ?? payload?.latitude ?? '';
105
+ const lon = att.longitude ?? payload?.longitude ?? '';
106
+ attachmentDescriptions.push(`[Location: ${lat}, ${lon}]`);
107
+ }
108
+ else if (attType === 'contact') {
109
+ const name = payload?.name ?? payload?.vcf_info ?? '';
110
+ attachmentDescriptions.push(`[Contact${name ? `: ${name}` : ''}]`);
111
+ }
112
+ else if (attType !== 'inline_keyboard') {
113
+ attachmentDescriptions.push(`[${attType}]`);
114
+ }
115
+ }
116
+ return { descriptions: attachmentDescriptions, mediaInputs };
117
+ }
118
+ /** Non-empty MAX transcription of an audio attachment, if any. */
119
+ function readAudioTranscription(att) {
120
+ const value = att.transcription;
121
+ if (typeof value !== 'string')
122
+ return undefined;
123
+ const trimmed = value.trim();
124
+ return trimmed || undefined;
125
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Inbound pipeline: DM/group gates, agent routing and context, then the
3
+ * reply dispatch (finalizeInboundContext → dispatchReplyWithBufferedBlockDispatcher).
4
+ */
5
+ import { toInboundMediaFacts } from 'openclaw/plugin-sdk/channel-inbound';
6
+ import { createReplyPrefixOptions } from 'openclaw/plugin-sdk/channel-outbound';
7
+ import { deliverMaxReply, withoutMaxButtons } from './deliver.js';
8
+ import { collectInboundAttachments } from './inbound-attachments.js';
9
+ import { materializeMaxPresentation } from './presentation.js';
10
+ import { getMaxRuntime } from './runtime.js';
11
+ import { readMaxChannelButtons, sendMaxMessage } from './send.js';
12
+ import { createMaxDraftStream } from './stream-draft.js';
13
+ /**
14
+ * Process incoming MAX message through OpenClaw pipeline (also the target of
15
+ * synthesized /start and button-press messages).
16
+ */
17
+ export async function processIncomingMessage(message, userLocale, opts) {
18
+ const { account, config, log, statusSink } = opts;
19
+ const core = getMaxRuntime();
20
+ const senderId = message.sender?.user_id;
21
+ const senderName = formatSenderName(message.sender);
22
+ const senderUsername = message.sender?.username ?? undefined;
23
+ // Determine chat type and IDs
24
+ const chatId = message.recipient.chat_id;
25
+ const chatType = message.recipient.chat_type; // "dialog", "chat", "channel"
26
+ const isGroup = chatType === 'chat' || chatType === 'channel';
27
+ const rawText = message.body.text ?? '';
28
+ const messageId = message.body.mid;
29
+ const isCallbackCommand = message.__maxCallback === true;
30
+ const attachments = message.body.attachments ?? [];
31
+ log?.debug?.(`[${account.accountId}] Processing message: mid=${messageId} chatId=${message.recipient.chat_id} chatType=${message.recipient.chat_type} senderId=${message.sender?.user_id} text="${rawText.slice(0, 50)}" attachments=${attachments.length}`);
32
+ // Skip truly empty messages (no text and no attachment that yields media or
33
+ // a description; inline keyboards yield neither). Attachments are
34
+ // downloaded only after the DM/group gates below, so ignored group chatter
35
+ // costs no traffic or disk.
36
+ if (!rawText.trim() && !attachments.some((att) => att.type !== 'inline_keyboard'))
37
+ return;
38
+ // Check for reply context
39
+ const replyToId = message.link?.type === 'reply' ? message.link.message?.body?.mid : undefined;
40
+ // Check for bot mention in group chats
41
+ let wasMentioned;
42
+ if (isGroup && (opts.botUsername || opts.botUserId)) {
43
+ // body.markup marks mentions as user_mention elements; the @botname regex
44
+ // stays as a fallback for clients/messages that send no markup.
45
+ wasMentioned = isBotMentionedInMarkup(message.body?.markup, opts.botUserId, opts.botUsername);
46
+ if (!wasMentioned && opts.botUsername) {
47
+ const mentionPattern = new RegExp(`@${escapeRegExp(opts.botUsername)}\\b`, 'i');
48
+ wasMentioned = mentionPattern.test(rawText);
49
+ }
50
+ // Reply to bot's message also counts as mention (like Telegram behavior)
51
+ if (!wasMentioned && message.link?.type === 'reply') {
52
+ const replySender = message.link.sender;
53
+ if (replySender?.is_bot && replySender?.user_id === opts.botUserId) {
54
+ wasMentioned = true;
55
+ log?.debug?.(`[${account.accountId}] Reply to bot message treated as mention`);
56
+ }
57
+ }
58
+ }
59
+ // Pressing a button on the bot's own keyboard is addressed to the bot, like a
60
+ // reply to its message — don't drop it at the group mention gate.
61
+ if (isGroup && isCallbackCommand) {
62
+ wasMentioned = true;
63
+ }
64
+ // DM security: check pairing/allowlist
65
+ if (!isGroup) {
66
+ const dmPolicy = account.config.dmPolicy ?? 'pairing';
67
+ if (dmPolicy === 'disabled') {
68
+ log?.debug?.(`[${account.accountId}] Blocked DM from ${senderId} (dmPolicy=disabled)`);
69
+ return;
70
+ }
71
+ if (dmPolicy !== 'open') {
72
+ const configAllowFrom = (account.config.allowFrom ?? []).map(String);
73
+ const storeAllowFrom = await core.channel.pairing
74
+ .readAllowFromStore({ channel: 'max', accountId: account.accountId })
75
+ .catch(() => []);
76
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
77
+ const senderStr = String(senderId);
78
+ const allowed = effectiveAllowFrom.includes(senderStr) || effectiveAllowFrom.includes('*');
79
+ if (!allowed) {
80
+ if (dmPolicy === 'pairing') {
81
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
82
+ channel: 'max',
83
+ id: senderStr,
84
+ accountId: account.accountId,
85
+ meta: { name: senderName },
86
+ });
87
+ if (created) {
88
+ log?.info(`[${account.accountId}] Pairing request from ${senderStr}`);
89
+ try {
90
+ const pairingReply = core.channel.pairing.buildPairingReply({
91
+ channel: 'max',
92
+ idLine: `Your MAX user id: ${senderStr}`,
93
+ code,
94
+ });
95
+ await sendMaxMessage(String(chatId ?? senderId), pairingReply, {
96
+ token: account.token,
97
+ });
98
+ statusSink?.({ lastOutboundAt: Date.now() });
99
+ }
100
+ catch (err) {
101
+ log?.error(`[${account.accountId}] Pairing reply failed: ${String(err)}`);
102
+ }
103
+ }
104
+ }
105
+ return;
106
+ }
107
+ }
108
+ }
109
+ // Group policy
110
+ if (isGroup) {
111
+ const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
112
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
113
+ if (groupPolicy === 'disabled') {
114
+ log?.debug?.(`[${account.accountId}] Blocked group message (groupPolicy=disabled)`);
115
+ return;
116
+ }
117
+ // For allowlist policy, check if chat is in the groups config
118
+ if (groupPolicy === 'allowlist') {
119
+ const groups = account.config.groups ?? {};
120
+ const chatIdStr = String(chatId);
121
+ const hasWildcard = '*' in groups;
122
+ const chatAllowed = chatIdStr in groups || hasWildcard;
123
+ if (!chatAllowed) {
124
+ log?.debug?.(`[${account.accountId}] Blocked group message (not in allowlist, chat=${chatIdStr})`);
125
+ return;
126
+ }
127
+ }
128
+ // Require mention in groups
129
+ const groupCfg = account.config.groups?.[String(chatId)] ?? account.config.groups?.['*'];
130
+ const requireMention = groupCfg?.requireMention ?? true;
131
+ if (requireMention && !wasMentioned) {
132
+ log?.debug?.(`[${account.accountId}] Skipping group message (not mentioned)`);
133
+ return;
134
+ }
135
+ }
136
+ // Process attachments: download media, build descriptions for non-downloadable types
137
+ const { descriptions: attachmentDescriptions, mediaInputs } = await collectInboundAttachments({
138
+ attachments,
139
+ messageId,
140
+ chatId,
141
+ api: opts.api,
142
+ account,
143
+ log,
144
+ });
145
+ const attachmentText = attachmentDescriptions.join(' ');
146
+ const hasMedia = mediaInputs.length > 0;
147
+ const effectiveText = rawText.trim() || attachmentText;
148
+ // Nothing usable came out of the attachments (e.g. a failed sticker download)
149
+ if (!effectiveText && !hasMedia)
150
+ 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
+ // Build context
168
+ const fromLabel = isGroup ? `chat:${chatIdStr}` : senderName || `user:${senderId}`;
169
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
170
+ agentId: route.agentId,
171
+ });
172
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
173
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
174
+ storePath,
175
+ sessionKey: route.sessionKey,
176
+ });
177
+ // Combine text and attachment descriptions for the agent
178
+ const bodyForAgent = attachmentText
179
+ ? rawText.trim()
180
+ ? `${rawText.trim()}\n${attachmentText}`
181
+ : attachmentText
182
+ : rawText;
183
+ const body = core.channel.reply.formatAgentEnvelope({
184
+ channel: 'MAX',
185
+ from: fromLabel,
186
+ timestamp: message.timestamp,
187
+ previousTimestamp,
188
+ envelope: envelopeOptions,
189
+ body: bodyForAgent,
190
+ });
191
+ // Detect text-slash commands (user types /status, /models, /reasoning etc.)
192
+ const rawTextTrimmed = (rawText || '').trim();
193
+ const isTextSlashCommand = rawTextTrimmed.startsWith('/');
194
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
195
+ Body: body,
196
+ BodyForAgent: bodyForAgent,
197
+ RawBody: rawText,
198
+ CommandBody: rawText || attachmentText,
199
+ From: `max:${senderId}`,
200
+ To: `max:${chatIdStr}`,
201
+ SessionKey: route.sessionKey,
202
+ AccountId: route.accountId,
203
+ ChatType: isGroup ? 'group' : 'direct',
204
+ ConversationLabel: fromLabel,
205
+ SenderName: senderName || undefined,
206
+ SenderId: senderId != null ? String(senderId) : undefined,
207
+ SenderUsername: senderUsername,
208
+ WasMentioned: isGroup ? wasMentioned : undefined,
209
+ Provider: 'max',
210
+ Surface: 'max',
211
+ MessageSid: messageId,
212
+ MessageSidFull: messageId,
213
+ ReplyToId: replyToId,
214
+ ReplyToIdFull: replyToId,
215
+ OriginatingChannel: 'max',
216
+ OriginatingTo: `max:${chatIdStr}`,
217
+ // Media attachments (downloaded to local paths) as ordered media facts
218
+ media: hasMedia ? toInboundMediaFacts(mediaInputs) : undefined,
219
+ // Text-slash command detection: treat /status, /models etc. as text commands
220
+ // so OpenClaw routes them through handleCommands instead of silently dropping
221
+ ...(isTextSlashCommand
222
+ ? {
223
+ CommandSource: 'text',
224
+ CommandTurn: {
225
+ kind: 'text-slash',
226
+ source: 'text',
227
+ authorized: undefined, // let allowFrom resolve authorization
228
+ body: rawTextTrimmed,
229
+ },
230
+ }
231
+ : {}),
232
+ });
233
+ // Record session meta
234
+ void core.channel.session
235
+ .recordSessionMetaFromInbound({
236
+ storePath,
237
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
238
+ ctx: ctxPayload,
239
+ })
240
+ .catch((err) => {
241
+ log?.error(`[${account.accountId}] Failed updating session meta: ${String(err)}`);
242
+ });
243
+ // Dispatch through the standard reply pipeline
244
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
245
+ cfg: config,
246
+ agentId: route.agentId,
247
+ channel: 'max',
248
+ accountId: route.accountId,
249
+ });
250
+ // Send typing indicator while agent processes
251
+ if (chatId != null) {
252
+ opts.api.sendAction(chatId, 'typing_on').catch((err) => {
253
+ log?.debug?.(`[${account.accountId}] typing_on failed: ${String(err)}`);
254
+ });
255
+ }
256
+ // Streaming modes: "partial" = edit single message, "block" = each block as separate message
257
+ const streamMode = account.config.streamMode ?? 'off';
258
+ const useEditStreaming = streamMode === 'partial';
259
+ const useBlockStreaming = streamMode === 'block';
260
+ const replyMid = isCallbackCommand ? undefined : messageId.replace(/_edited_\d+$/, '');
261
+ const callbackId = isCallbackCommand ? messageId : undefined;
262
+ // Draft stream for edit-streaming (like Telegram's partial reply approach)
263
+ const draft = createMaxDraftStream({
264
+ account,
265
+ chatId: chatIdStr,
266
+ replyToId: replyMid,
267
+ log,
268
+ statusSink,
269
+ });
270
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
271
+ ctx: ctxPayload,
272
+ cfg: config,
273
+ dispatcherOptions: {
274
+ ...prefixOptions,
275
+ deliver: async (rawPayload) => {
276
+ // This funnel consumes ReplyPayload directly, so it must apply the same
277
+ // presentation fallback/render policy as core's outbound path.
278
+ const payload = await materializeMaxPresentation(rawPayload);
279
+ if (useEditStreaming && draft.messageId && payload.text) {
280
+ await draft.finalize(payload.text, readMaxChannelButtons(payload.channelData));
281
+ // Handle media if present (buttons already sit on the draft)
282
+ if (payload.mediaUrls?.length || payload.mediaUrl) {
283
+ await deliverMaxReply({
284
+ payload: {
285
+ ...payload,
286
+ text: undefined,
287
+ channelData: withoutMaxButtons(payload.channelData),
288
+ },
289
+ account,
290
+ chatId: chatIdStr,
291
+ replyToId: replyMid,
292
+ callbackId,
293
+ config,
294
+ log,
295
+ statusSink,
296
+ });
297
+ }
298
+ return;
299
+ }
300
+ // Non-streaming path or no draft yet
301
+ await deliverMaxReply({
302
+ payload,
303
+ account,
304
+ chatId: chatIdStr,
305
+ replyToId: replyMid,
306
+ callbackId,
307
+ config,
308
+ log,
309
+ statusSink,
310
+ });
311
+ },
312
+ onError: (err, info) => {
313
+ log?.error(`[${account.accountId}] MAX ${info.kind} reply failed: ${String(err)}`);
314
+ },
315
+ },
316
+ replyOptions: {
317
+ onModelSelected,
318
+ ...(useEditStreaming
319
+ ? {
320
+ onPartialReply: (payload) => {
321
+ if (payload.text)
322
+ draft.update(payload.text);
323
+ },
324
+ }
325
+ : {}),
326
+ ...(useBlockStreaming ? { disableBlockStreaming: false } : {}),
327
+ },
328
+ });
329
+ // Cleanup draft stream
330
+ await draft.clear();
331
+ }
332
+ /**
333
+ * Whether body.markup has a user_mention of this bot: by user_id (users
334
+ * without a username) or by user_link `@username` (case-insensitive).
335
+ * @internal exported for testing.
336
+ */
337
+ export function isBotMentionedInMarkup(markup, botUserId, botUsername) {
338
+ if (!Array.isArray(markup))
339
+ return false;
340
+ const username = botUsername?.replace(/^@/, '').toLowerCase();
341
+ return markup.some((element) => {
342
+ if (element?.type !== 'user_mention')
343
+ return false;
344
+ if (botUserId != null && element.user_id === botUserId)
345
+ return true;
346
+ const link = typeof element.user_link === 'string'
347
+ ? element.user_link.replace(/^@/, '').toLowerCase()
348
+ : '';
349
+ return Boolean(username && link === username);
350
+ });
351
+ }
352
+ function escapeRegExp(value) {
353
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
354
+ }
355
+ function formatSenderName(user) {
356
+ if (!user)
357
+ return 'Unknown';
358
+ const parts = [user.first_name];
359
+ if (user.last_name)
360
+ parts.push(user.last_name);
361
+ return parts.join(' ') || user.username || `user_${user.user_id}`;
362
+ }
@@ -0,0 +1,92 @@
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
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { getMaxRuntime } from './runtime.js';
9
+ /** Upper bound for a sanitized file name (bytes of UTF-8 stay well under 255). */
10
+ export const MAX_TEMP_FILE_NAME_LENGTH = 120;
11
+ const EXTENSION_BY_CONTENT_TYPE = {
12
+ 'image/jpeg': 'jpg',
13
+ 'image/png': 'png',
14
+ 'image/gif': 'gif',
15
+ 'image/webp': 'webp',
16
+ 'image/heic': 'heic',
17
+ 'image/tiff': 'tiff',
18
+ 'image/bmp': 'bmp',
19
+ 'video/mp4': 'mp4',
20
+ 'video/quicktime': 'mov',
21
+ 'video/x-matroska': 'mkv',
22
+ 'video/webm': 'webm',
23
+ 'audio/mpeg': 'mp3',
24
+ 'audio/ogg': 'ogg',
25
+ 'audio/mp4': 'm4a',
26
+ 'audio/wav': 'wav',
27
+ 'audio/aac': 'aac',
28
+ 'audio/flac': 'flac',
29
+ 'audio/opus': 'opus',
30
+ 'application/pdf': 'pdf',
31
+ 'text/plain': 'txt',
32
+ };
33
+ /**
34
+ * A remote file name reduced to one safe path segment: no directories, no
35
+ * `..`, no control or path-reserved characters, bounded length, extension
36
+ * kept. Falls back to `file` (plus an extension from contentType if known).
37
+ */
38
+ export function sanitizeMaxFileName(name, contentType) {
39
+ let base = (name ?? '').normalize('NFC');
40
+ // Last path segment only (both separators; URL-encoded ones too).
41
+ base =
42
+ base
43
+ .replace(/%2f|%5c/gi, '/')
44
+ .split(/[/\\]/)
45
+ .pop() ?? '';
46
+ base = base
47
+ .replace(/[\u0000-\u001f\u007f-\u009f<>:"|?*]/g, '_')
48
+ .replace(/\s+/g, ' ')
49
+ .replace(/\.{2,}/g, '.')
50
+ .replace(/^[.\s]+|[.\s]+$/g, '');
51
+ const dot = base.lastIndexOf('.');
52
+ let stem = dot > 0 ? base.slice(0, dot) : base;
53
+ let ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
54
+ if (!/^[a-z0-9]{1,10}$/.test(ext)) {
55
+ if (ext)
56
+ stem = base;
57
+ ext = '';
58
+ }
59
+ if (!ext && contentType)
60
+ ext = EXTENSION_BY_CONTENT_TYPE[contentType.split(';')[0].trim().toLowerCase()] ?? '';
61
+ if (!stem)
62
+ stem = 'file';
63
+ const room = MAX_TEMP_FILE_NAME_LENGTH - (ext ? ext.length + 1 : 0);
64
+ stem = Array.from(stem).slice(0, room).join('').trim() || 'file';
65
+ return ext ? `${stem}.${ext}` : stem;
66
+ }
67
+ /**
68
+ * Download `url` through the runtime media fetcher (size-capped), write it to
69
+ * a private temp directory under a sanitized name, run `use`, then remove the
70
+ * directory whatever happens.
71
+ */
72
+ export async function withRemoteMediaTempFile(url, maxBytes, use) {
73
+ const loaded = await getMaxRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
74
+ const fileName = sanitizeMaxFileName(loaded.fileName ?? fileNameFromUrl(url), loaded.contentType);
75
+ const dir = await mkdtemp(join(tmpdir(), 'max-media-'));
76
+ try {
77
+ const path = join(dir, fileName);
78
+ await writeFile(path, loaded.buffer, { mode: 0o600 });
79
+ return await use({ path, contentType: loaded.contentType, fileName });
80
+ }
81
+ finally {
82
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
83
+ }
84
+ }
85
+ function fileNameFromUrl(url) {
86
+ try {
87
+ return decodeURIComponent(new URL(url).pathname.split('/').pop() ?? '') || undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }