@aspalagin/openclaw-max 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
@@ -2,15 +2,29 @@
2
2
  * Inbound attachments: downloads media (image, sticker, video, audio, file)
3
3
  * into the gateway media store and describes the rest as text for the agent.
4
4
  */
5
- import { sanitizeMaxFileName } from './media-temp.js';
5
+ import { resolveMaxAccountNetwork } from './accounts.js';
6
+ import { fetchMaxRemoteMedia, sanitizeMaxFileName } from './media-temp.js';
6
7
  import { getMaxRuntime } from './runtime.js';
7
8
  import { rememberStickerCode } from './sticker-cache.js';
9
+ /** Default most media attachments downloaded per inbound message: a full MAX album. */
10
+ export const DEFAULT_INBOUND_MEDIA_MAX_COUNT = 12;
11
+ /** Per-message media count limit of the account (`mediaMaxCount`). */
12
+ export function resolveInboundMediaMaxCount(account) {
13
+ return account.config.mediaMaxCount ?? DEFAULT_INBOUND_MEDIA_MAX_COUNT;
14
+ }
15
+ /** A voice message whose audio could not be loaded and has no MAX transcript. */
16
+ const VOICE_UNAVAILABLE = '[Voice message: audio unavailable, no transcript]';
17
+ /** Longest link-preview description handed to the agent. */
18
+ const MAX_SHARE_DESCRIPTION_CHARS = 300;
8
19
  /** Process attachments: download media, build descriptions for non-downloadable types. */
9
20
  export async function collectInboundAttachments(params) {
10
21
  const { attachments, messageId, chatId, api, account, log } = params;
11
22
  const core = getMaxRuntime();
12
23
  const attachmentDescriptions = [];
13
24
  const mediaInputs = [];
25
+ const mediaBudget = params.mediaBudget ?? resolveInboundMediaMaxCount(account);
26
+ let mediaTaken = 0;
27
+ let mediaSkipped = 0;
14
28
  for (const att of attachments) {
15
29
  const attType = att.type ?? 'unknown';
16
30
  const payload = att.payload;
@@ -25,6 +39,23 @@ export async function collectInboundAttachments(params) {
25
39
  rememberStickerCode(chatId, stickerCode);
26
40
  }
27
41
  }
42
+ // MAX may transcribe voice messages itself (AudioAttachment.transcription,
43
+ // a sibling of payload). The platform transcript wins: the text goes to
44
+ // the agent and the audio fact is marked transcribed (the SDK contract
45
+ // for platform transcripts), so core media understanding skips STT.
46
+ // Without it the fact stays untranscribed and core STT
47
+ // (tools.media.audio) runs; a failed STT leaves core's marker.
48
+ const transcription = attType === 'audio' ? readAudioTranscription(att) : undefined;
49
+ if (transcription) {
50
+ attachmentDescriptions.push(`[Voice transcript: ${transcription}]`);
51
+ }
52
+ // Over the per-message count limit: no lookup, no download; the text
53
+ // parts above (sticker code, transcript) are cheap and stay.
54
+ if (mediaTaken >= mediaBudget) {
55
+ mediaSkipped += 1;
56
+ continue;
57
+ }
58
+ mediaTaken += 1;
28
59
  let url = (payload?.url ?? att.url ?? '');
29
60
  // Inbound video attachments often carry only a token — resolve playback
30
61
  // URLs via GET /videos/{videoToken} instead of degrading to "[video]".
@@ -48,42 +79,48 @@ export async function collectInboundAttachments(params) {
48
79
  log?.debug?.(`[${account.accountId}] getVideoInfo failed: ${String(err)}`);
49
80
  }
50
81
  }
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
82
  if (url && typeof url === 'string' && url.startsWith('http')) {
59
83
  try {
60
84
  const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
61
- const fetched = await core.channel.media.fetchRemoteMedia({ url, maxBytes });
85
+ const { proxyUrl } = resolveMaxAccountNetwork(account);
86
+ const fetched = await fetchMaxRemoteMedia(url, maxBytes, proxyUrl);
62
87
  const inboundFileName = fetched.fileName
63
88
  ? sanitizeMaxFileName(fetched.fileName, fetched.contentType)
64
89
  : undefined;
65
90
  const saved = await core.channel.media.saveMediaBuffer(Buffer.from(fetched.buffer), fetched.contentType, 'inbound', maxBytes, inboundFileName);
66
91
  // Only the local copy goes to the agent: MAX download URLs are signed
67
92
  // and short-lived, so they are not recorded as the media url.
93
+ // A MAX audio attachment is a voice message: the explicit kind keeps
94
+ // core STT selecting it even when the CDN answers with a generic
95
+ // content type.
68
96
  mediaInputs.push({
69
97
  path: saved.path,
70
98
  contentType: saved.contentType,
71
99
  fileName: inboundFileName,
72
100
  messageId,
101
+ ...(attType === 'audio' ? { kind: 'audio' } : {}),
73
102
  ...(transcription ? { transcribed: true } : {}),
74
103
  });
75
104
  }
76
105
  catch (err) {
77
106
  log?.error?.(`[${account.accountId}] Failed to download ${attType}: ${String(err)}`);
78
107
  // Fall back to text description (sticker code already added above)
79
- if (attType !== 'sticker') {
108
+ if (attType === 'audio') {
109
+ if (!transcription)
110
+ attachmentDescriptions.push(VOICE_UNAVAILABLE);
111
+ }
112
+ else if (attType !== 'sticker') {
80
113
  attachmentDescriptions.push(`[${attType}: ${url}]`);
81
114
  }
82
115
  }
83
116
  }
84
117
  else {
85
118
  // No URL — text description
86
- if (attType === 'sticker') {
119
+ if (attType === 'audio') {
120
+ if (!transcription)
121
+ attachmentDescriptions.push(VOICE_UNAVAILABLE);
122
+ }
123
+ else if (attType === 'sticker') {
87
124
  const code = payload?.code ?? '';
88
125
  attachmentDescriptions.push(`[Sticker${code ? `: ${code}` : ''}]`);
89
126
  }
@@ -97,26 +134,131 @@ export async function collectInboundAttachments(params) {
97
134
  }
98
135
  }
99
136
  else if (attType === 'share') {
100
- const url = (payload?.url ?? att.url ?? '');
101
- attachmentDescriptions.push(`[Share${url ? `: ${url}` : ''}]`);
137
+ attachmentDescriptions.push(describeShare(att));
102
138
  }
103
139
  else if (attType === 'location') {
104
- const lat = att.latitude ?? payload?.latitude ?? '';
105
- const lon = att.longitude ?? payload?.longitude ?? '';
106
- attachmentDescriptions.push(`[Location: ${lat}, ${lon}]`);
140
+ attachmentDescriptions.push(describeLocation(att));
107
141
  }
108
142
  else if (attType === 'contact') {
109
- const name = payload?.name ?? payload?.vcf_info ?? '';
110
- attachmentDescriptions.push(`[Contact${name ? `: ${name}` : ''}]`);
143
+ attachmentDescriptions.push(describeContact(payload));
111
144
  }
112
145
  else if (attType !== 'inline_keyboard') {
113
146
  attachmentDescriptions.push(`[${attType}]`);
114
147
  }
115
148
  }
116
- return { descriptions: attachmentDescriptions, mediaInputs };
149
+ if (mediaSkipped > 0) {
150
+ log?.warn?.(`[${account.accountId}] ${mediaSkipped} media attachment(s) of ${messageId} not loaded: mediaMaxCount`);
151
+ attachmentDescriptions.push(`[${mediaSkipped} more media attachment(s) not loaded: limit of ${resolveInboundMediaMaxCount(account)} per message]`);
152
+ }
153
+ return { descriptions: attachmentDescriptions, mediaInputs, mediaTaken };
154
+ }
155
+ /** One line of untrusted text: whitespace collapsed, clipped by code points. */
156
+ function toInlineText(value, maxChars) {
157
+ if (typeof value !== 'string')
158
+ return '';
159
+ const chars = Array.from(value.replace(/\s+/g, ' ').trim());
160
+ if (maxChars === undefined || chars.length <= maxChars)
161
+ return chars.join('');
162
+ return `${chars.slice(0, maxChars - 1).join('')}…`;
163
+ }
164
+ /** Link preview card: title, description and link, whichever MAX sent. */
165
+ function describeShare(att) {
166
+ const raw = att;
167
+ const payload = raw.payload;
168
+ const url = toInlineText(payload?.url ?? raw.url);
169
+ const parts = [
170
+ toInlineText(raw.title),
171
+ toInlineText(raw.description, MAX_SHARE_DESCRIPTION_CHARS),
172
+ url,
173
+ ].filter(Boolean);
174
+ return parts.length ? `[Share: ${parts.join(' — ')}]` : '[Share]';
175
+ }
176
+ /** Coordinates with a map link (a Yandex Maps placemark). */
177
+ function describeLocation(att) {
178
+ const raw = att;
179
+ const payload = raw.payload;
180
+ const lat = Number(raw.latitude ?? payload?.latitude);
181
+ const lon = Number(raw.longitude ?? payload?.longitude);
182
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)
183
+ return '[Location]';
184
+ return `[Location: ${lat}, ${lon} — https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map]`;
185
+ }
186
+ /** Contact card: name and phones from the VCard, the MAX profile if linked; never the raw VCard. */
187
+ function describeContact(payload) {
188
+ const vcard = typeof payload?.vcf_info === 'string' ? parseMaxVcard(payload.vcf_info) : undefined;
189
+ const user = payload?.max_info;
190
+ const profileName = [user?.first_name, user?.last_name]
191
+ .map((part) => toInlineText(part))
192
+ .filter(Boolean)
193
+ .join(' ');
194
+ const name = vcard?.name || profileName || toInlineText(payload?.name);
195
+ const parts = name ? [name] : [];
196
+ if (vcard?.phones.length)
197
+ parts.push(`phone: ${vcard.phones.join(', ')}`);
198
+ if (user && typeof user.user_id === 'number') {
199
+ const username = toInlineText(user.username);
200
+ parts.push(`MAX user: ${user.user_id}${username ? ` (@${username.replace(/^@/, '')})` : ''}`);
201
+ }
202
+ return parts.length ? `[Contact: ${parts.join('; ')}]` : '[Contact]';
203
+ }
204
+ /** Most phone numbers taken from one VCard. */
205
+ const MAX_VCARD_PHONES = 5;
206
+ /**
207
+ * Display name and phone numbers of a VCard (2.1/3.0/4.0): FN, else N; TEL
208
+ * values (a `tel:` URI prefix dropped). Folded lines, escapes and
209
+ * quoted-printable values are decoded; everything else is ignored.
210
+ * @internal exported for testing.
211
+ */
212
+ export function parseMaxVcard(vcf) {
213
+ const lines = vcf.replace(/\r?\n[ \t]/g, '').split(/\r?\n/);
214
+ let fullName = '';
215
+ let structuredName = '';
216
+ const phones = [];
217
+ for (const line of lines) {
218
+ const colon = line.indexOf(':');
219
+ if (colon <= 0)
220
+ continue;
221
+ const [rawProperty, ...params] = line.slice(0, colon).split(';');
222
+ const property = rawProperty.replace(/^[^.]*\./, '').toUpperCase();
223
+ let value = line.slice(colon + 1);
224
+ if (params.some((param) => /^ENCODING=QUOTED-PRINTABLE$/i.test(param))) {
225
+ value = decodeQuotedPrintable(value);
226
+ }
227
+ if (property === 'FN' && !fullName) {
228
+ fullName = toInlineText(unescapeVcardValue(value));
229
+ }
230
+ else if (property === 'N' && !structuredName) {
231
+ // N: family;given;additional;prefix;suffix
232
+ const [family = '', given = ''] = value.split(';').map(unescapeVcardValue);
233
+ structuredName = toInlineText(`${given} ${family}`);
234
+ }
235
+ else if (property === 'TEL') {
236
+ const phone = toInlineText(unescapeVcardValue(value).replace(/^tel:/i, ''));
237
+ if (phone && !phones.includes(phone) && phones.length < MAX_VCARD_PHONES)
238
+ phones.push(phone);
239
+ }
240
+ }
241
+ return { name: fullName || structuredName, phones };
242
+ }
243
+ function unescapeVcardValue(value) {
244
+ return value.replace(/\\([nN,;\\])/g, (_, ch) => (ch === 'n' || ch === 'N' ? ' ' : ch));
245
+ }
246
+ function decodeQuotedPrintable(value) {
247
+ const bytes = [];
248
+ for (let i = 0; i < value.length; i += 1) {
249
+ const hex = value[i] === '=' ? value.slice(i + 1, i + 3) : '';
250
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
251
+ bytes.push(parseInt(hex, 16));
252
+ i += 2;
253
+ }
254
+ else {
255
+ bytes.push(...Buffer.from(value[i], 'utf8'));
256
+ }
257
+ }
258
+ return Buffer.from(bytes).toString('utf8');
117
259
  }
118
260
  /** Non-empty MAX transcription of an audio attachment, if any. */
119
- function readAudioTranscription(att) {
261
+ export function readAudioTranscription(att) {
120
262
  const value = att.transcription;
121
263
  if (typeof value !== 'string')
122
264
  return undefined;