@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/deliver.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Reply delivery for the inbound pipeline: callback answers, markdown chunks
3
+ * with the keyboard on the last one, media albums and delivery.pin.
4
+ */
5
+
6
+ import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
7
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
8
+
9
+ import type { ResolvedMaxAccount } from './accounts.js';
10
+ import { readMaxDeliveryPin } from './presentation.js';
11
+ import { getMaxRuntime } from './runtime.js';
12
+ import {
13
+ answerMaxCallback,
14
+ pinMaxMessage,
15
+ readMaxChannelButtons,
16
+ readMaxChannelSendOptions,
17
+ sendMaxMediaGroup,
18
+ sendMaxMessage,
19
+ } from './send.js';
20
+
21
+ export async function deliverMaxReply(params: {
22
+ payload: {
23
+ text?: string;
24
+ mediaUrls?: string[];
25
+ mediaUrl?: string;
26
+ replyToId?: string;
27
+ channelData?: unknown;
28
+ delivery?: unknown;
29
+ };
30
+ account: ResolvedMaxAccount;
31
+ chatId: string;
32
+ replyToId?: string;
33
+ callbackId?: string;
34
+ config: OpenClawConfig;
35
+ log?: ChannelLogSink;
36
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
37
+ }): Promise<void> {
38
+ const { payload, account, chatId, config, log, statusSink } = params;
39
+ const core = getMaxRuntime();
40
+ const buttons = readMaxChannelButtons(payload.channelData);
41
+ const sendOptions = readMaxChannelSendOptions(payload.channelData);
42
+
43
+ if (params.callbackId && (payload.text || buttons?.length)) {
44
+ try {
45
+ await answerMaxCallback(params.callbackId, payload.text ?? '', {
46
+ token: account.token,
47
+ format: 'markdown',
48
+ buttons,
49
+ });
50
+ statusSink?.({ lastOutboundAt: Date.now() });
51
+ } catch (err: unknown) {
52
+ const body = (err as { body?: unknown })?.body;
53
+ log?.error(
54
+ `[${account.accountId}] MAX callback answer failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
55
+ );
56
+ }
57
+ return;
58
+ }
59
+
60
+ // delivery.pin: pin the first delivered message (first chunk).
61
+ let firstMessageId: string | undefined;
62
+ let firstChatType: string | undefined;
63
+ const noteDelivered = (messageId: string, chatType?: string) => {
64
+ if (!firstMessageId && messageId) {
65
+ firstMessageId = messageId;
66
+ firstChatType = chatType;
67
+ }
68
+ };
69
+
70
+ if (payload.text) {
71
+ const chunkLimit = 4000; // MAX message limit
72
+ const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
73
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
74
+
75
+ for (let index = 0; index < chunks.length; index += 1) {
76
+ const chunk = chunks[index];
77
+ try {
78
+ const sent = await sendMaxMessage(chatId, chunk, {
79
+ token: account.token,
80
+ replyToMessageId: params.replyToId,
81
+ format: 'markdown',
82
+ buttons: index === chunks.length - 1 ? buttons : undefined,
83
+ ...sendOptions,
84
+ });
85
+ noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
86
+ statusSink?.({ lastOutboundAt: Date.now() });
87
+ } catch (err: unknown) {
88
+ const body = (err as { body?: unknown })?.body;
89
+ log?.error(
90
+ `[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
91
+ );
92
+ }
93
+ }
94
+ } else if (buttons?.length) {
95
+ try {
96
+ const sent = await sendMaxMessage(chatId, '', {
97
+ token: account.token,
98
+ replyToMessageId: params.replyToId,
99
+ format: 'markdown',
100
+ buttons,
101
+ ...sendOptions,
102
+ });
103
+ noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
104
+ statusSink?.({ lastOutboundAt: Date.now() });
105
+ } catch (err: unknown) {
106
+ const body = (err as { body?: unknown })?.body;
107
+ log?.error(
108
+ `[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ // Media URLs — upload and send
114
+ const mediaList = payload.mediaUrls?.length
115
+ ? payload.mediaUrls
116
+ : payload.mediaUrl
117
+ ? [payload.mediaUrl]
118
+ : [];
119
+
120
+ // Images/videos go as albums (up to 12 per message); https image links are
121
+ // sent by URL, other remote media is downloaded and uploaded.
122
+ if (mediaList.length) {
123
+ const sent = await sendMaxMediaGroup(chatId, '', mediaList, {
124
+ token: account.token,
125
+ replyToMessageId: params.replyToId,
126
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
127
+ ...sendOptions,
128
+ onError: (err, failed) =>
129
+ log?.error(
130
+ `[${account.accountId}] MAX media send failed (${failed.length} item(s)): ${String(err)}`,
131
+ ),
132
+ });
133
+ for (const id of sent.messageIds) noteDelivered(id);
134
+ if (sent.messageIds.length) statusSink?.({ lastOutboundAt: Date.now() });
135
+ }
136
+
137
+ const pin = readMaxDeliveryPin(payload.delivery);
138
+ if (pin && firstMessageId) {
139
+ try {
140
+ const pinned = await pinMaxMessage(chatId, firstMessageId, {
141
+ token: account.token,
142
+ pinNotify: pin.notify === true,
143
+ chatType: firstChatType,
144
+ });
145
+ if (!pinned.pinned) {
146
+ log?.debug?.(
147
+ `[${account.accountId}] MAX pin of ${firstMessageId} skipped: ${pinned.reason}`,
148
+ );
149
+ }
150
+ } catch (err) {
151
+ // Optional pins degrade; the delivered message stays.
152
+ log?.[pin.required ? 'error' : 'warn'](
153
+ `[${account.accountId}] MAX pin of ${firstMessageId} failed: ${String(err)}`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ /** channelData with `max.buttons` removed (other max options kept). */
160
+ export function withoutMaxButtons(channelData: unknown): unknown {
161
+ if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
162
+ return channelData;
163
+ const maxData = (channelData as Record<string, unknown>).max;
164
+ if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return channelData;
165
+ const rest = { ...(maxData as Record<string, unknown>) };
166
+ delete rest.buttons;
167
+ return { ...(channelData as Record<string, unknown>), max: rest };
168
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Update dispatch: routes each MAX update by update_type — messages and edits
3
+ * to the inbound pipeline, button presses to callbacks, bot/dialog lifecycle
4
+ * events to the chat registry.
5
+ */
6
+
7
+ import type { ResolvedMaxAccount } from './accounts.js';
8
+ import type { MaxMessage, MaxUpdate, MaxUser } from './api.js';
9
+ import { processCallback } from './callbacks.js';
10
+ import { processIncomingMessage } from './inbound.js';
11
+ import type { MaxMonitorOptions } from './monitor-types.js';
12
+
13
+ /** mark_seen vanished from current MAX docs; keep behind config (default on). */
14
+ function shouldMarkSeen(account: ResolvedMaxAccount): boolean {
15
+ return account.config.markSeen !== false;
16
+ }
17
+
18
+ function sendReadReceipt(chatId: number | undefined, opts: MaxMonitorOptions): void {
19
+ const { log, account } = opts;
20
+ if (!chatId) return;
21
+ if (shouldMarkSeen(account)) {
22
+ opts.api.sendAction(chatId, 'mark_seen').catch((err) => {
23
+ log?.debug?.(`[${account.accountId}] mark_seen failed: ${String(err)}`);
24
+ });
25
+ }
26
+ // typing_on is sent once, by processIncomingMessage right before the agent
27
+ // run — only for messages that pass the group/DM gates.
28
+ }
29
+
30
+ /** Route one update by update_type (shared by the polling loop and the webhook queue). */
31
+ export async function dispatchUpdate(update: MaxUpdate, opts: MaxMonitorOptions): Promise<void> {
32
+ const { log, account, statusSink } = opts;
33
+
34
+ switch (update.update_type) {
35
+ case 'message_created': {
36
+ if (!update.message) break;
37
+ // Skip messages from the bot itself
38
+ if (opts.botUserId && update.message.sender?.user_id === opts.botUserId) break;
39
+ statusSink?.({ lastInboundAt: Date.now() });
40
+ // Mark message as read (typing starts once the message reaches the agent)
41
+ sendReadReceipt(update.message.recipient?.chat_id, opts);
42
+ // Passive chat discovery: GET /chats is deprecated, register group chats
43
+ // the bot actually sees so directory.listGroups keeps working.
44
+ const recipient = update.message.recipient;
45
+ if (
46
+ opts.state &&
47
+ recipient?.chat_id != null &&
48
+ (recipient.chat_type === 'chat' || recipient.chat_type === 'channel') &&
49
+ !opts.state.hasActiveChat(recipient.chat_id)
50
+ ) {
51
+ // A message from this chat proves the bot is a member — (re)register it,
52
+ // clearing any stale removedAt from an earlier bot_removed/dialog_removed.
53
+ opts.state.upsertChat(recipient.chat_id, {
54
+ type: recipient.chat_type,
55
+ addedAt: Date.now(),
56
+ });
57
+ }
58
+ await processIncomingMessage(update.message, update.user_locale, opts);
59
+ break;
60
+ }
61
+
62
+ case 'message_callback': {
63
+ if (!update.callback) break;
64
+ statusSink?.({ lastInboundAt: Date.now() });
65
+ await processCallback(update.callback, update.message ?? null, update.user_locale, opts);
66
+ break;
67
+ }
68
+
69
+ case 'message_edited': {
70
+ if (!update.message) break;
71
+ // Skip edits from the bot itself
72
+ if (opts.botUserId && update.message.sender?.user_id === opts.botUserId) break;
73
+ log?.debug?.(
74
+ `[${account.accountId}] Message edited: ${update.message?.body?.mid} text="${update.message?.body?.text ?? '<null>'}" hasBody=${!!update.message?.body}`,
75
+ );
76
+ statusSink?.({ lastInboundAt: Date.now() });
77
+ // Mark as read (typing starts once the message reaches the agent)
78
+ sendReadReceipt(update.message.recipient?.chat_id, opts);
79
+ // Process edited message through the same pipeline as new messages.
80
+ // Use a unique mid suffix to avoid OpenClaw dedup (same mid = skipped).
81
+ const editedMessage = { ...update.message };
82
+ const originalMid = editedMessage.body.mid;
83
+ editedMessage.body = {
84
+ ...editedMessage.body,
85
+ mid: `${originalMid}_edited_${update.timestamp}`,
86
+ };
87
+
88
+ // MAX message_edited may not include text — fetch it from API if missing
89
+ if (!editedMessage.body.text?.trim() && originalMid) {
90
+ try {
91
+ const chatId = editedMessage.recipient?.chat_id;
92
+ if (chatId) {
93
+ const fetched = await opts.api.getMessages(chatId, {
94
+ message_ids: [originalMid],
95
+ count: 1,
96
+ });
97
+ const fetchedMsg = fetched.messages?.[0];
98
+ if (fetchedMsg?.body?.text) {
99
+ editedMessage.body = { ...editedMessage.body, text: fetchedMsg.body.text };
100
+ if (fetchedMsg.body.attachments?.length) {
101
+ editedMessage.body.attachments = fetchedMsg.body.attachments;
102
+ }
103
+ log?.debug?.(
104
+ `[${account.accountId}] Fetched edited text: "${fetchedMsg.body.text.slice(0, 50)}"`,
105
+ );
106
+ }
107
+ }
108
+ } catch (err) {
109
+ log?.debug?.(
110
+ `[${account.accountId}] Failed to fetch edited message text: ${String(err)}`,
111
+ );
112
+ }
113
+ }
114
+
115
+ await processIncomingMessage(editedMessage, update.user_locale, opts);
116
+ break;
117
+ }
118
+
119
+ case 'bot_started': {
120
+ if (!update.user) break;
121
+ log?.info(
122
+ `[${account.accountId}] Bot started by user ${update.user.user_id}${update.payload ? ' (with deeplink payload)' : ''}`,
123
+ );
124
+ statusSink?.({ lastInboundAt: Date.now() });
125
+ if (opts.state && update.chat_id != null) {
126
+ opts.state.upsertChat(update.chat_id, {
127
+ type: 'dialog',
128
+ addedAt: Date.now(),
129
+ stopped: false,
130
+ });
131
+ }
132
+ await processBotStarted(update.user, update.chat_id, update.payload ?? undefined, opts);
133
+ break;
134
+ }
135
+
136
+ case 'bot_stopped': {
137
+ // User halted the bot in a dialog — stop proactive sends until they return
138
+ log?.info(
139
+ `[${account.accountId}] Bot stopped by user ${update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`,
140
+ );
141
+ if (opts.state && update.chat_id != null) {
142
+ opts.state.upsertChat(update.chat_id, { type: 'dialog', stopped: true });
143
+ }
144
+ break;
145
+ }
146
+
147
+ case 'bot_added': {
148
+ log?.info(`[${account.accountId}] Bot added to chat ${update.chat_id}`);
149
+ if (opts.state && update.chat_id != null) {
150
+ opts.state.upsertChat(update.chat_id, {
151
+ type: update.is_channel ? 'channel' : 'chat',
152
+ addedAt: Date.now(),
153
+ });
154
+ }
155
+ break;
156
+ }
157
+
158
+ case 'bot_removed': {
159
+ log?.info(`[${account.accountId}] Bot removed from chat ${update.chat_id}`);
160
+ if (opts.state && update.chat_id != null) {
161
+ opts.state.upsertChat(update.chat_id, { removedAt: Date.now() });
162
+ }
163
+ break;
164
+ }
165
+
166
+ case 'dialog_removed': {
167
+ log?.info(
168
+ `[${account.accountId}] Dialog removed by user ${update.user_id ?? update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`,
169
+ );
170
+ if (opts.state && update.chat_id != null) {
171
+ opts.state.upsertChat(update.chat_id, { removedAt: Date.now() });
172
+ }
173
+ break;
174
+ }
175
+
176
+ case 'dialog_cleared': {
177
+ // User wiped the dialog history on their side; keep our session but log it
178
+ log?.info(
179
+ `[${account.accountId}] Dialog cleared by user ${update.user_id ?? update.user?.user_id ?? '?'} (chat ${update.chat_id ?? '?'})`,
180
+ );
181
+ break;
182
+ }
183
+
184
+ case 'chat_title_changed': {
185
+ if (opts.state && update.chat_id != null && typeof update.title === 'string') {
186
+ opts.state.upsertChat(update.chat_id, { title: update.title });
187
+ }
188
+ break;
189
+ }
190
+
191
+ case 'message_removed': {
192
+ // Deliberate no-op: OpenClaw sessions have no per-message retraction.
193
+ log?.debug?.(
194
+ `[${account.accountId}] Message removed in chat ${update.chat_id ?? '?'}: ${(update as { message_id?: string }).message_id ?? '?'}`,
195
+ );
196
+ break;
197
+ }
198
+
199
+ default:
200
+ log?.debug?.(`[${account.accountId}] Unhandled update type: ${update.update_type}`);
201
+ }
202
+ }
203
+
204
+ async function processBotStarted(
205
+ user: MaxUser,
206
+ chatId: number | undefined,
207
+ payload: string | undefined,
208
+ opts: MaxMonitorOptions,
209
+ ): Promise<void> {
210
+ // Synthesize a /start message; deeplink payload (max.ru/<bot>?start=...) is
211
+ // forwarded as the command argument like other messengers do.
212
+ const startText = payload?.trim() ? `/start ${payload.trim()}` : '/start';
213
+ const syntheticMessage: MaxMessage = {
214
+ sender: user,
215
+ recipient: { chat_id: chatId ?? user.user_id, chat_type: 'dialog' },
216
+ timestamp: Date.now(),
217
+ body: {
218
+ mid: `bot_started_${user.user_id}_${Date.now()}`,
219
+ text: startText,
220
+ },
221
+ };
222
+
223
+ await processIncomingMessage(syntheticMessage, null, opts);
224
+ }
package/src/format.ts ADDED
@@ -0,0 +1,33 @@
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
+
15
+ const CODE_OR_URL = /(```[\s\S]*?```|`[^`]*`|https?:\/\/\S+)/g;
16
+
17
+ /** Заменяет только <u>…</u> → ++…++; markdown-подчёркивание MAX не поддерживает. */
18
+ function convertSegment(text: string): string {
19
+ return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++');
20
+ }
21
+
22
+ /**
23
+ * Конвертирует OpenClaw/стандартный markdown в MAX markdown, не затрагивая
24
+ * содержимое inline-кода, fenced-блоков и URL.
25
+ */
26
+ export function toMaxMarkdown(text: string): string {
27
+ if (!text) return text;
28
+ // split с захватывающей группой: нечётные индексы — код/URL (не трогаем)
29
+ return text
30
+ .split(CODE_OR_URL)
31
+ .map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
32
+ .join('');
33
+ }
@@ -0,0 +1,152 @@
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
+
6
+ import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
7
+ import type { ChannelInboundMediaInput } from 'openclaw/plugin-sdk/channel-inbound';
8
+
9
+ import type { ResolvedMaxAccount } from './accounts.js';
10
+ import type { MaxApi, MaxAttachment } from './api.js';
11
+ import { sanitizeMaxFileName } from './media-temp.js';
12
+ import { getMaxRuntime } from './runtime.js';
13
+ import { rememberStickerCode } from './sticker-cache.js';
14
+
15
+ export interface MaxInboundAttachments {
16
+ /** Text descriptions ([Sticker: …], [Location: …], [Voice transcript: …], failed downloads). */
17
+ descriptions: string[];
18
+ /** One entry per downloaded attachment; array position is attachment identity. */
19
+ mediaInputs: ChannelInboundMediaInput[];
20
+ }
21
+
22
+ /** Process attachments: download media, build descriptions for non-downloadable types. */
23
+ export async function collectInboundAttachments(params: {
24
+ attachments: MaxAttachment[];
25
+ messageId: string;
26
+ chatId: number | undefined;
27
+ api: MaxApi;
28
+ account: ResolvedMaxAccount;
29
+ log?: ChannelLogSink;
30
+ }): Promise<MaxInboundAttachments> {
31
+ const { attachments, messageId, chatId, api, account, log } = params;
32
+ const core = getMaxRuntime();
33
+ const attachmentDescriptions: string[] = [];
34
+ const mediaInputs: ChannelInboundMediaInput[] = [];
35
+
36
+ for (const att of attachments) {
37
+ const attType = att.type ?? 'unknown';
38
+ const payload = att.payload as Record<string, unknown> | undefined;
39
+
40
+ // Media types with downloadable URL: image, sticker, video, audio, file
41
+ if (['image', 'sticker', 'video', 'audio', 'file'].includes(attType)) {
42
+ // For stickers, always capture the code for outbound use
43
+ const stickerCode = attType === 'sticker' ? ((payload?.code ?? '') as string) : '';
44
+ if (stickerCode) {
45
+ log?.debug?.(`[${account.accountId}] Sticker received: code=${stickerCode}`);
46
+ attachmentDescriptions.push(`[Sticker: code=${stickerCode}]`);
47
+ if (chatId != null) {
48
+ rememberStickerCode(chatId, stickerCode);
49
+ }
50
+ }
51
+
52
+ let url = (payload?.url ?? (att as Record<string, unknown>).url ?? '') as string;
53
+
54
+ // Inbound video attachments often carry only a token — resolve playback
55
+ // URLs via GET /videos/{videoToken} instead of degrading to "[video]".
56
+ if (!url && attType === 'video' && typeof payload?.token === 'string' && payload.token) {
57
+ try {
58
+ const info = await api.getVideoInfo(payload.token);
59
+ const urls = info?.urls ?? undefined;
60
+ url =
61
+ urls?.mp4_720 ??
62
+ urls?.mp4_480 ??
63
+ urls?.mp4_1080 ??
64
+ urls?.mp4_360 ??
65
+ urls?.mp4_240 ??
66
+ urls?.mp4_144 ??
67
+ '';
68
+ if (!url) {
69
+ log?.debug?.(
70
+ `[${account.accountId}] Video ${payload.token.slice(0, 12)}… has no playback URLs yet`,
71
+ );
72
+ }
73
+ } catch (err) {
74
+ log?.debug?.(`[${account.accountId}] getVideoInfo failed: ${String(err)}`);
75
+ }
76
+ }
77
+
78
+ // MAX may transcribe voice messages itself (AudioAttachment.transcription,
79
+ // a sibling of payload). The text goes to the agent and the audio fact is
80
+ // marked transcribed, so core media understanding does not run STT again.
81
+ const transcription = attType === 'audio' ? readAudioTranscription(att) : undefined;
82
+ if (transcription) {
83
+ attachmentDescriptions.push(`[Voice transcript: ${transcription}]`);
84
+ }
85
+
86
+ if (url && typeof url === 'string' && url.startsWith('http')) {
87
+ try {
88
+ const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
89
+ const fetched = await core.channel.media.fetchRemoteMedia({ url, maxBytes });
90
+ const inboundFileName = fetched.fileName
91
+ ? sanitizeMaxFileName(fetched.fileName, fetched.contentType)
92
+ : undefined;
93
+ const saved = await core.channel.media.saveMediaBuffer(
94
+ Buffer.from(fetched.buffer),
95
+ fetched.contentType,
96
+ 'inbound',
97
+ maxBytes,
98
+ inboundFileName,
99
+ );
100
+ // Only the local copy goes to the agent: MAX download URLs are signed
101
+ // and short-lived, so they are not recorded as the media url.
102
+ mediaInputs.push({
103
+ path: saved.path,
104
+ contentType: saved.contentType,
105
+ fileName: inboundFileName,
106
+ messageId,
107
+ ...(transcription ? { transcribed: true } : {}),
108
+ });
109
+ } catch (err) {
110
+ log?.error?.(`[${account.accountId}] Failed to download ${attType}: ${String(err)}`);
111
+ // Fall back to text description (sticker code already added above)
112
+ if (attType !== 'sticker') {
113
+ attachmentDescriptions.push(`[${attType}: ${url}]`);
114
+ }
115
+ }
116
+ } else {
117
+ // No URL — text description
118
+ if (attType === 'sticker') {
119
+ const code = payload?.code ?? '';
120
+ attachmentDescriptions.push(`[Sticker${code ? `: ${code}` : ''}]`);
121
+ } else if (attType === 'file') {
122
+ const filename = (att as Record<string, unknown>).filename ?? payload?.filename ?? '';
123
+ attachmentDescriptions.push(`[File${filename ? `: ${filename}` : ''}]`);
124
+ } else {
125
+ attachmentDescriptions.push(`[${attType}]`);
126
+ }
127
+ }
128
+ } else if (attType === 'share') {
129
+ const url = (payload?.url ?? (att as Record<string, unknown>).url ?? '') as string;
130
+ attachmentDescriptions.push(`[Share${url ? `: ${url}` : ''}]`);
131
+ } else if (attType === 'location') {
132
+ const lat = (att as Record<string, unknown>).latitude ?? payload?.latitude ?? '';
133
+ const lon = (att as Record<string, unknown>).longitude ?? payload?.longitude ?? '';
134
+ attachmentDescriptions.push(`[Location: ${lat}, ${lon}]`);
135
+ } else if (attType === 'contact') {
136
+ const name = payload?.name ?? payload?.vcf_info ?? '';
137
+ attachmentDescriptions.push(`[Contact${name ? `: ${name}` : ''}]`);
138
+ } else if (attType !== 'inline_keyboard') {
139
+ attachmentDescriptions.push(`[${attType}]`);
140
+ }
141
+ }
142
+
143
+ return { descriptions: attachmentDescriptions, mediaInputs };
144
+ }
145
+
146
+ /** Non-empty MAX transcription of an audio attachment, if any. */
147
+ function readAudioTranscription(att: MaxAttachment): string | undefined {
148
+ const value = att.transcription;
149
+ if (typeof value !== 'string') return undefined;
150
+ const trimmed = value.trim();
151
+ return trimmed || undefined;
152
+ }