dsh-plugin-weapp-gateway 0.8.11 → 0.8.13

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.
@@ -0,0 +1,31 @@
1
+ /** Check a cold Session's recorded mode without acquiring a writer or changing it. */
2
+ export async function assertColdSessionPresetAvailable(ctx, sessionId, signal) {
3
+ signal?.throwIfAborted();
4
+ const agents = ctx.get('agents');
5
+ const presets = ctx.get('agentPresets');
6
+ const query = ctx.get('sessionQuery');
7
+ if (typeof agents?.get !== 'function' || agents.get(sessionId)
8
+ || typeof presets?.resolve !== 'function' || typeof query?.observeSession !== 'function') return;
9
+
10
+ const observation = await query.observeSession(sessionId, { signal, projectionMode: 'all' });
11
+ try {
12
+ signal?.throwIfAborted();
13
+ const selected = observation.projections?.values?.agentPreset;
14
+ if (selected !== null && typeof selected !== 'string') return;
15
+ try {
16
+ const mode = await presets.resolve(selected ?? undefined);
17
+ signal?.throwIfAborted();
18
+ if (mode.broken === undefined) return;
19
+ } catch (error) {
20
+ signal?.throwIfAborted();
21
+ if (error?.code !== 'agent-preset/not-found' && error?.code !== 'agent-preset/invalid') throw error;
22
+ }
23
+ if (agents.get(sessionId)) return;
24
+ throw Object.assign(new Error('此会话的模式未启用或暂不可用;请在电脑上检查对应模式,或选择其他会话。未发送本次消息。'), {
25
+ code: 'adapter/session-preset-unavailable',
26
+ details: { sessionId, ...(typeof selected === 'string' ? { agentPreset: selected } : {}) },
27
+ });
28
+ } finally {
29
+ observation[Symbol.dispose]();
30
+ }
31
+ }
@@ -0,0 +1,321 @@
1
+ /** Tencent/openclaw-weixin 24de5c9: api/types, cdn/pic-decrypt,
2
+ * cdn/aes-ecb, media/media-download, auth/accounts. See third-party notice.
3
+ * Bounds and exact-host restriction are our local policy, not provider limits. */
4
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
5
+ import { ILinkError } from './ilink-types.mjs';
6
+ export const MEDIA_MAX_BYTES = 10 * 1024 * 1024;
7
+ export const MEDIA_MAX_ITEMS = 6;
8
+ const CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c';
9
+ function bad() { throw new ILinkError('clawbot-invalid-response', '微信附件信息不完整,未发送到会话'); }
10
+ function large() { throw new ILinkError('clawbot-response-too-large', '微信附件超过单个 10 MiB 的处理上限'); }
11
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
12
+ function mediaUrl(value, purpose = 'download') {
13
+ if (typeof value !== 'string' || !value || value.length > 8192 || /[\x00-\x20\x7f\\]/u.test(value))
14
+ bad();
15
+ let url;
16
+ try {
17
+ url = new URL(value);
18
+ }
19
+ catch {
20
+ return bad();
21
+ }
22
+ if (url.protocol !== 'https:' || url.hostname !== 'novac2c.cdn.weixin.qq.com'
23
+ || url.port || url.username || url.password || url.hash || url.pathname !== '/c2c/' + purpose || !url.search) {
24
+ throw new ILinkError('clawbot-untrusted-url', '微信附件地址不在允许范围内');
25
+ }
26
+ return url.href;
27
+ }
28
+ function aesKey(value) {
29
+ if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{22,44}={0,2}$/u.test(value))
30
+ bad();
31
+ const bytes = Buffer.from(value, 'base64');
32
+ if (bytes.toString('base64').replace(/=+$/u, '') !== value.replace(/=+$/u, ''))
33
+ bad();
34
+ if (bytes.length === 16)
35
+ return bytes;
36
+ if (bytes.length === 32 && /^[a-fA-F0-9]{32}$/u.test(bytes.toString('ascii')))
37
+ return Buffer.from(bytes.toString('ascii'), 'hex');
38
+ return bad();
39
+ }
40
+ /** Keep a filename, never a provider-supplied filesystem path or control name. */
41
+ function safeName(value, fallback) {
42
+ if (value === undefined || value === '')
43
+ return fallback;
44
+ if (typeof value !== 'string' || value.length > 4096)
45
+ bad();
46
+ let name = value.split(/[\\/]/u).at(-1).replace(/[\p{Cc}\p{Cf}<>:"|?*]/gu, '_').trim().replace(/[. ]+$/u, '');
47
+ if (!name || name === '.' || name === '..')
48
+ return fallback;
49
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(name))
50
+ name = '_' + name;
51
+ const extension = /(?<!^)\.[a-zA-Z0-9]{1,16}$/u.exec(name)?.[0] || '';
52
+ let stem = extension ? name.slice(0, -extension.length) : name;
53
+ while (Buffer.byteLength(stem + extension) > 240)
54
+ stem = [...stem].slice(0, -1).join('');
55
+ name = stem + extension;
56
+ return name || fallback;
57
+ }
58
+ /** Validate persisted refs as well as fresh provider input before any download. */
59
+ export function validateMedia(value) {
60
+ try {
61
+ if (!object(value) || !['image', 'file', 'voice', 'video'].includes(value.kind)
62
+ || typeof value.name !== 'string' || !value.name || safeName(value.name, '') !== value.name
63
+ || value.bytes !== undefined && (!Number.isSafeInteger(value.bytes) || value.bytes < 0 || value.bytes > MEDIA_MAX_BYTES)
64
+ || value.md5 !== undefined && (typeof value.md5 !== 'string' || !/^[a-fA-F0-9]{32}$/u.test(value.md5)))
65
+ return false;
66
+ mediaUrl(value.url);
67
+ if (value.aesKey !== undefined)
68
+ aesKey(value.aesKey);
69
+ return value.kind === 'image' || value.aesKey !== undefined;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ function opaque(value, max = 4096) {
76
+ return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= max && !/[\s\x00-\x1f\x7f]/u.test(value);
77
+ }
78
+ /** Outbox validation rejects oversized, cross-shape and malformed persisted references. */
79
+ export function validatePreparedMedia(value) {
80
+ try {
81
+ if (!object(value) || !['image', 'file', 'video'].includes(value.kind)
82
+ || typeof value.name !== 'string' || !value.name || safeName(value.name, '') !== value.name
83
+ || !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > MEDIA_MAX_BYTES
84
+ || value.cipherBytes !== (Math.floor(value.bytes / 16) + 1) * 16
85
+ || !opaque(value.query) || !opaque(value.accountId, 256) || !opaque(value.userId, 256) || !value.userId.endsWith('@im.wechat')
86
+ || typeof value.aesKey !== 'string')
87
+ return false;
88
+ // Outbound follows Tencent's base64(ASCII hex32), not its inbound raw-key variant.
89
+ const decoded = Buffer.from(value.aesKey, 'base64');
90
+ return decoded.length === 32 && /^[a-f0-9]{32}$/u.test(decoded.toString('ascii')) && decoded.toString('base64') === value.aesKey;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ /** One CDN write, with no auth forwarding or retry (including ambiguous failures). */
97
+ async function uploadEncrypted(url, data, fetchImpl, signal) {
98
+ signal?.throwIfAborted();
99
+ const controller = new AbortController(), forward = () => controller.abort(signal?.reason);
100
+ signal?.addEventListener('abort', forward, { once: true });
101
+ let timedOut = false;
102
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 30_000);
103
+ try {
104
+ const response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' },
105
+ body: new Uint8Array(data), redirect: 'error', signal: controller.signal });
106
+ controller.signal.throwIfAborted();
107
+ await response.body?.cancel();
108
+ controller.signal.throwIfAborted();
109
+ if (response.redirected || response.status >= 300 && response.status < 400 || response.url && response.url !== url) {
110
+ throw new ILinkError('clawbot-untrusted-url', '微信附件上传发生了不允许的跳转');
111
+ }
112
+ if (response.status !== 200)
113
+ throw new ILinkError('clawbot-http-error', '微信附件暂时无法上传');
114
+ const query = response.headers.get('x-encrypted-param');
115
+ if (!opaque(query))
116
+ throw new ILinkError('clawbot-invalid-response', '微信附件上传结果无法确认');
117
+ return query;
118
+ }
119
+ catch (error) {
120
+ signal?.throwIfAborted();
121
+ if (timedOut)
122
+ throw new ILinkError('clawbot-timeout', '微信附件上传超时');
123
+ if (error instanceof ILinkError)
124
+ throw error;
125
+ throw new ILinkError('clawbot-network-error', '微信附件上传结果无法确认');
126
+ }
127
+ finally {
128
+ clearTimeout(timer);
129
+ signal?.removeEventListener('abort', forward);
130
+ }
131
+ }
132
+ /** Tencent's getuploadurl -> AES/PKCS7 -> CDN protocol, without local file access. */
133
+ export async function prepareMedia(auth, input, fetchImpl, getUploadUrl, signal, check) {
134
+ if (!object(input) || !['image', 'file', 'voice', 'video'].includes(input.kind)
135
+ || !opaque(input.userId, 256) || !input.userId.endsWith('@im.wechat') || input.userId !== auth.userId
136
+ || typeof input.name !== 'string' || !input.name || !(input.data instanceof Uint8Array) || !input.data.byteLength) {
137
+ throw new ILinkError('clawbot-invalid-input', '微信附件缺少有效内容或收件人');
138
+ }
139
+ if (input.data.byteLength > MEDIA_MAX_BYTES)
140
+ large();
141
+ // Snapshot bytes, names and identities before awaiting; callers may change their state.
142
+ const kind = input.kind === 'voice' ? 'file' : input.kind;
143
+ const name = safeName(input.name, 'file.bin'), data = Buffer.from(input.data);
144
+ const accountId = auth.accountId, userId = input.userId;
145
+ const key = randomBytes(16), filekey = randomBytes(16).toString('hex');
146
+ const cipher = createCipheriv('aes-128-ecb', key, null);
147
+ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
148
+ const checkpoint = () => { signal?.throwIfAborted(); check?.(); };
149
+ checkpoint();
150
+ const info = await getUploadUrl({ filekey, media_type: kind === 'image' ? 1 : kind === 'video' ? 2 : 3,
151
+ to_user_id: userId, rawsize: data.length, rawfilemd5: createHash('md5').update(data).digest('hex'),
152
+ filesize: encrypted.length, no_need_thumb: true, aeskey: key.toString('hex') });
153
+ checkpoint();
154
+ let url;
155
+ if (info.upload_full_url !== undefined && info.upload_full_url !== '')
156
+ url = mediaUrl(info.upload_full_url, 'upload');
157
+ else {
158
+ if (!opaque(info.upload_param))
159
+ throw new ILinkError('clawbot-invalid-response', '微信附件上传地址不完整');
160
+ url = mediaUrl(CDN_BASE + '/upload?encrypted_query_param=' + encodeURIComponent(info.upload_param) + '&filekey=' + filekey, 'upload');
161
+ }
162
+ checkpoint();
163
+ const query = await uploadEncrypted(url, encrypted, fetchImpl, signal);
164
+ checkpoint();
165
+ return { kind, name, bytes: data.length, cipherBytes: encrypted.length, query,
166
+ aesKey: Buffer.from(key.toString('hex'), 'ascii').toString('base64'), accountId, userId };
167
+ }
168
+ /** Decode only native direct media fields. Quotes/stickers are not fabricated. */
169
+ export function mediaFromItem(item) {
170
+ const kinds = { 2: 'image', 3: 'voice', 4: 'file', 5: 'video' };
171
+ const kind = kinds[item.type];
172
+ if (!kind)
173
+ bad();
174
+ const part = item[kind === 'image' ? 'image_item' : kind === 'voice' ? 'voice_item' : kind === 'file' ? 'file_item' : 'video_item'];
175
+ if (!object(part) || !object(part.media))
176
+ bad();
177
+ const media = part.media;
178
+ let url;
179
+ if (media.full_url !== undefined && media.full_url !== '')
180
+ url = mediaUrl(media.full_url);
181
+ else {
182
+ if (typeof media.encrypt_query_param !== 'string' || !media.encrypt_query_param
183
+ || media.encrypt_query_param.length > 4096 || /[\x00-\x20\x7f]/u.test(media.encrypt_query_param))
184
+ bad();
185
+ url = mediaUrl(CDN_BASE + '/download?encrypted_query_param=' + encodeURIComponent(media.encrypt_query_param));
186
+ }
187
+ let key;
188
+ if (kind === 'image' && part.aeskey !== undefined && part.aeskey !== '') {
189
+ if (typeof part.aeskey !== 'string' || !/^[a-fA-F0-9]{32}$/u.test(part.aeskey))
190
+ bad();
191
+ key = Buffer.from(part.aeskey, 'hex').toString('base64');
192
+ }
193
+ else if (media.aes_key !== undefined && media.aes_key !== '')
194
+ key = aesKey(media.aes_key).toString('base64');
195
+ if (kind !== 'image' && !key)
196
+ bad();
197
+ const voiceExtensions = { 1: 'pcm', 2: 'adpcm', 3: 'feature', 4: 'speex', 5: 'amr', 6: 'silk', 7: 'mp3', 8: 'ogg' };
198
+ const name = kind === 'file' ? safeName(part.file_name, 'file.bin')
199
+ : kind === 'voice' ? 'voice.' + (voiceExtensions[part.encode_type] || 'bin') : kind === 'video' ? 'video.mp4' : 'image.bin';
200
+ const result = { kind, name, url, ...(key ? { aesKey: key } : {}) };
201
+ // File len is the plaintext length. Image sizes can describe variants and are
202
+ // not used as integrity assertions; the streaming cap still applies to all.
203
+ if (kind === 'file' && part.len !== undefined && part.len !== '') {
204
+ if (typeof part.len !== 'string' || !/^\d{1,16}$/u.test(part.len) || !Number.isSafeInteger(Number(part.len)))
205
+ bad();
206
+ result.bytes = Number(part.len);
207
+ if (result.bytes > MEDIA_MAX_BYTES)
208
+ large();
209
+ }
210
+ const md5 = kind === 'file' ? part.md5 : kind === 'video' ? part.video_md5 : undefined;
211
+ if (md5 !== undefined && md5 !== '') {
212
+ if (typeof md5 !== 'string' || !/^[a-fA-F0-9]{32}$/u.test(md5))
213
+ bad();
214
+ result.md5 = md5.toLowerCase();
215
+ }
216
+ if (!validateMedia(result))
217
+ bad();
218
+ return result;
219
+ }
220
+ function detectedName(media, data) {
221
+ if (media.kind === 'image') {
222
+ const hex = data.subarray(0, 12).toString('hex');
223
+ const ext = hex.startsWith('89504e470d0a1a0a') ? 'png' : hex.startsWith('ffd8ff') ? 'jpg'
224
+ : /^474946383[79]61/u.test(hex) ? 'gif'
225
+ : data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP' ? 'webp'
226
+ : hex.startsWith('424d') ? 'bmp' : undefined;
227
+ return ext ? 'image.' + ext : media.name;
228
+ }
229
+ if (media.kind === 'voice' && media.name === 'voice.bin') {
230
+ if (data.subarray(0, 10).toString('ascii').includes('#!SILK_V3'))
231
+ return 'voice.silk';
232
+ if (data.toString('ascii', 0, 5) === '#!AMR')
233
+ return 'voice.amr';
234
+ if (data.toString('ascii', 0, 4) === 'OggS')
235
+ return 'voice.ogg';
236
+ if (data.toString('ascii', 0, 3) === 'ID3')
237
+ return 'voice.mp3';
238
+ if (data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WAVE')
239
+ return 'voice.wav';
240
+ }
241
+ return media.name;
242
+ }
243
+ export async function downloadMedia(media, fetchImpl, signal) {
244
+ if (!validateMedia(media))
245
+ throw new ILinkError('clawbot-invalid-input', '微信附件信息无效,未下载');
246
+ signal?.throwIfAborted();
247
+ const controller = new AbortController(), forward = () => controller.abort(signal?.reason);
248
+ signal?.addEventListener('abort', forward, { once: true });
249
+ let timedOut = false;
250
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, 30_000);
251
+ try {
252
+ const response = await fetchImpl(media.url, { method: 'GET', redirect: 'error', signal: controller.signal });
253
+ controller.signal.throwIfAborted();
254
+ if (response.redirected || response.status >= 300 && response.status < 400 || response.url && response.url !== media.url) {
255
+ await response.body?.cancel();
256
+ throw new ILinkError('clawbot-untrusted-url', '微信附件下载发生了不允许的跳转');
257
+ }
258
+ if (!response.ok || !response.body) {
259
+ await response.body?.cancel();
260
+ throw new ILinkError('clawbot-http-error', '微信附件暂时无法下载,未发送到会话');
261
+ }
262
+ const cap = MEDIA_MAX_BYTES + (media.aesKey ? 16 : 0), length = response.headers.get('content-length');
263
+ if (length && (!/^\d+$/u.test(length) || Number(length) > cap)) {
264
+ await response.body.cancel();
265
+ large();
266
+ }
267
+ const reader = response.body.getReader(), chunks = [];
268
+ let size = 0;
269
+ const abortRead = () => { void reader.cancel().catch(() => { }); };
270
+ controller.signal.addEventListener('abort', abortRead, { once: true });
271
+ try {
272
+ while (true) {
273
+ controller.signal.throwIfAborted();
274
+ const part = await reader.read();
275
+ controller.signal.throwIfAborted();
276
+ if (part.done)
277
+ break;
278
+ size += part.value.byteLength;
279
+ if (size > cap) {
280
+ await reader.cancel();
281
+ large();
282
+ }
283
+ chunks.push(part.value);
284
+ }
285
+ }
286
+ finally {
287
+ controller.signal.removeEventListener('abort', abortRead);
288
+ reader.releaseLock();
289
+ }
290
+ let data = Buffer.concat(chunks);
291
+ if (media.aesKey) {
292
+ try {
293
+ const decipher = createDecipheriv('aes-128-ecb', aesKey(media.aesKey), null);
294
+ data = Buffer.concat([decipher.update(data), decipher.final()]);
295
+ }
296
+ catch {
297
+ throw new ILinkError('clawbot-invalid-response', '微信附件解密失败,未发送到会话;请在小程序或电脑上传原文件');
298
+ }
299
+ }
300
+ if (data.length > MEDIA_MAX_BYTES)
301
+ large();
302
+ if (!data.length || media.bytes !== undefined && data.length !== media.bytes
303
+ || media.md5 && createHash('md5').update(data).digest('hex') !== media.md5.toLowerCase()) {
304
+ throw new ILinkError('clawbot-invalid-response', '微信附件完整性校验失败,未发送到会话');
305
+ }
306
+ controller.signal.throwIfAborted();
307
+ return { name: detectedName(media, data), data };
308
+ }
309
+ catch (error) {
310
+ signal?.throwIfAborted();
311
+ if (timedOut)
312
+ throw new ILinkError('clawbot-timeout', '微信附件下载超时,未发送到会话');
313
+ if (error instanceof ILinkError)
314
+ throw error;
315
+ throw new ILinkError('clawbot-network-error', '微信附件暂时无法下载,未发送到会话');
316
+ }
317
+ finally {
318
+ clearTimeout(timer);
319
+ signal?.removeEventListener('abort', forward);
320
+ }
321
+ }
@@ -0,0 +1,13 @@
1
+ /** Error messages deliberately exclude remote response bodies, credentials and URLs. */
2
+ export class ILinkError extends Error {
3
+ code;
4
+ apiCode;
5
+ httpStatus;
6
+ constructor(code, message, apiCode, httpStatus) {
7
+ super(message);
8
+ this.code = code;
9
+ this.apiCode = apiCode;
10
+ this.httpStatus = httpStatus;
11
+ this.name = 'ILinkError';
12
+ }
13
+ }