@aspalagin/openclaw-max 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- 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 {
|
|
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
|
|
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
|
|
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 === '
|
|
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
|
-
|
|
101
|
-
attachmentDescriptions.push(`[Share${url ? `: ${url}` : ''}]`);
|
|
137
|
+
attachmentDescriptions.push(describeShare(att));
|
|
102
138
|
}
|
|
103
139
|
else if (attType === 'location') {
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|