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.
- package/PROTOCOL.md +6 -0
- package/README.md +13 -2
- package/THIRD-PARTY-NOTICES/clawbot.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/clawbot/contracts.mjs +8 -0
- package/lib/clawbot/dsh-host.mjs +560 -0
- package/lib/clawbot/host-admission.mjs +31 -0
- package/lib/clawbot/ilink-media.mjs +321 -0
- package/lib/clawbot/ilink-types.mjs +13 -0
- package/lib/clawbot/ilink.mjs +489 -0
- package/lib/clawbot/native-peer.mjs +29 -0
- package/lib/clawbot/reply-text.mjs +29 -0
- package/lib/clawbot/runtime.mjs +974 -0
- package/lib/clawbot/secure-file.mjs +86 -0
- package/lib/clawbot/service.mjs +81 -0
- package/lib/clawbot/storage.mjs +108 -0
- package/lib/client.js +7 -1
- package/lib/completion-notifications.mjs +86 -7
- package/lib/dsh-host-adapter.mjs +0 -6
- package/lib/index.mjs +11 -7
- package/lib/plugin-updates.mjs +39 -1
- package/package.json +54 -51
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapted from Tencent/openclaw-weixin 24de5c9 (MIT).
|
|
3
|
+
* Copyright (C) 2026 Tencent. See THIRD-PARTY-NOTICES/clawbot.txt.
|
|
4
|
+
*
|
|
5
|
+
* This adapter implements HTTP transport, not OpenClaw routing or agent execution.
|
|
6
|
+
* Resource caps and URL restrictions below are local policy, not iLink guarantees.
|
|
7
|
+
* There is no assumption about token TTL, concurrent consumers or exactly-once delivery.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { ILinkError } from './ilink-types.mjs';
|
|
11
|
+
import { downloadMedia, mediaFromItem, MEDIA_MAX_ITEMS, prepareMedia, validatePreparedMedia } from './ilink-media.mjs';
|
|
12
|
+
export { ILinkError } from './ilink-types.mjs';
|
|
13
|
+
export { validatePreparedMedia } from './ilink-media.mjs';
|
|
14
|
+
const API_BASE = 'https://ilinkai.weixin.qq.com';
|
|
15
|
+
// Wire compatibility version of the extracted upstream code, not this plugin's version.
|
|
16
|
+
const CHANNEL_VERSION = '2.4.9';
|
|
17
|
+
const CLIENT_VERSION = String((2 << 16) | (4 << 8) | 9);
|
|
18
|
+
const BASE_INFO = { channel_version: CHANNEL_VERSION, bot_agent: 'DSHRemote/1.0.0' };
|
|
19
|
+
const API_TIMEOUT_MS = 15_000;
|
|
20
|
+
const POLL_TIMEOUT_MS = 35_000;
|
|
21
|
+
const MAX_POLL_TIMEOUT_MS = 60_000;
|
|
22
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
23
|
+
const MAX_MESSAGES = 100;
|
|
24
|
+
const MAX_TEXT_BYTES = 256 * 1024;
|
|
25
|
+
const MAX_CURSOR_BYTES = 64 * 1024;
|
|
26
|
+
const MAX_QR_SESSIONS = 8;
|
|
27
|
+
const MAX_QR_REDIRECTS = 3;
|
|
28
|
+
function object(value) {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function invalid(message = '微信返回了无法识别的数据') {
|
|
32
|
+
throw new ILinkError('clawbot-invalid-response', message);
|
|
33
|
+
}
|
|
34
|
+
function boundedString(value, maximum, allowEmpty = false) {
|
|
35
|
+
return typeof value === 'string' && (allowEmpty || value.trim().length > 0)
|
|
36
|
+
&& Buffer.byteLength(value, 'utf8') <= maximum;
|
|
37
|
+
}
|
|
38
|
+
function identifier(value) {
|
|
39
|
+
return boundedString(value, 256) && !/[\s\x00-\x1f\x7f]/u.test(value);
|
|
40
|
+
}
|
|
41
|
+
function privateUser(value) {
|
|
42
|
+
return identifier(value) && value.endsWith('@im.wechat');
|
|
43
|
+
}
|
|
44
|
+
function messageId(value) {
|
|
45
|
+
return typeof value === 'string' && /^\d{1,20}$/u.test(value) && BigInt(value) <= 0xffffffffffffffffn;
|
|
46
|
+
}
|
|
47
|
+
function secret(value) {
|
|
48
|
+
return boundedString(value, 16 * 1024) && !/[\s\x00-\x1f\x7f]/u.test(value);
|
|
49
|
+
}
|
|
50
|
+
function safeUrl(value, purpose) {
|
|
51
|
+
if (!boundedString(value, 8192) || value !== value.trim() || /[\x00-\x20\x7f\\]/u.test(value)) {
|
|
52
|
+
throw new ILinkError('clawbot-untrusted-url', '微信返回的地址不在允许范围内');
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new ILinkError('clawbot-untrusted-url', '微信返回的地址不在允许范围内');
|
|
60
|
+
}
|
|
61
|
+
const root = purpose === 'api' ? 'ilinkai.weixin.qq.com' : 'weixin.qq.com';
|
|
62
|
+
const trusted = parsed.hostname === root || parsed.hostname.endsWith(`.${root}`);
|
|
63
|
+
if (!trusted || parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password
|
|
64
|
+
|| parsed.hash || (purpose === 'api' && (parsed.pathname !== '/' || parsed.search))) {
|
|
65
|
+
throw new ILinkError('clawbot-untrusted-url', '微信返回的地址不在允许范围内');
|
|
66
|
+
}
|
|
67
|
+
// QR links are only displayed/encoded. This does not promise same-phone navigation.
|
|
68
|
+
return purpose === 'api' ? parsed.origin : parsed.href;
|
|
69
|
+
}
|
|
70
|
+
function credentials(value) {
|
|
71
|
+
if (!object(value) || !secret(value.token) || !identifier(value.accountId) || !privateUser(value.userId)) {
|
|
72
|
+
throw new ILinkError('clawbot-invalid-input', '微信连接凭据不完整,请重新连接');
|
|
73
|
+
}
|
|
74
|
+
return safeUrl(value.baseUrl, 'api');
|
|
75
|
+
}
|
|
76
|
+
function checkApiResult(body) {
|
|
77
|
+
// Native getUpdates/sendMessage responses may omit both status fields.
|
|
78
|
+
// Missing is not failure; explicit errors and malformed fields still fail.
|
|
79
|
+
// Endpoint-specific payload validation remains with each caller below.
|
|
80
|
+
for (const key of ['ret', 'errcode']) {
|
|
81
|
+
if (body[key] !== undefined && (typeof body[key] !== 'number' || !Number.isSafeInteger(body[key])))
|
|
82
|
+
invalid();
|
|
83
|
+
}
|
|
84
|
+
if (body.ret === -14 || body.errcode === -14) {
|
|
85
|
+
throw new ILinkError('clawbot-auth-expired', '微信连接已失效,请重新连接', -14);
|
|
86
|
+
}
|
|
87
|
+
const failure = [body.ret, body.errcode].find((code) => typeof code === 'number' && code !== 0);
|
|
88
|
+
if (typeof failure === 'number') {
|
|
89
|
+
throw new ILinkError('clawbot-api-error', '微信暂未接受此请求', failure);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Keep uint64 message IDs exact, following the upstream scanner before JSON.parse. */
|
|
93
|
+
function parseJson(raw) {
|
|
94
|
+
const idFields = new Set(['message_id', 'msg_id', 'svr_id']);
|
|
95
|
+
let output = '', index = 0;
|
|
96
|
+
while (index < raw.length) {
|
|
97
|
+
if (raw[index] !== '"') {
|
|
98
|
+
output += raw[index++];
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const start = index++;
|
|
102
|
+
let escaped = false;
|
|
103
|
+
while (index < raw.length) {
|
|
104
|
+
const char = raw[index++];
|
|
105
|
+
if (escaped)
|
|
106
|
+
escaped = false;
|
|
107
|
+
else if (char === '\\')
|
|
108
|
+
escaped = true;
|
|
109
|
+
else if (char === '"')
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
const token = raw.slice(start, index);
|
|
113
|
+
output += token;
|
|
114
|
+
let cursor = index;
|
|
115
|
+
while (/\s/u.test(raw[cursor] ?? ''))
|
|
116
|
+
cursor++;
|
|
117
|
+
if (raw[cursor] !== ':')
|
|
118
|
+
continue;
|
|
119
|
+
const key = JSON.parse(token);
|
|
120
|
+
if (typeof key !== 'string' || !idFields.has(key))
|
|
121
|
+
continue;
|
|
122
|
+
output += raw.slice(index, cursor + 1);
|
|
123
|
+
cursor++;
|
|
124
|
+
while (/\s/u.test(raw[cursor] ?? ''))
|
|
125
|
+
output += raw[cursor++];
|
|
126
|
+
const numberStart = cursor;
|
|
127
|
+
if (raw[cursor] === '-')
|
|
128
|
+
cursor++;
|
|
129
|
+
while (/\d/u.test(raw[cursor] ?? ''))
|
|
130
|
+
cursor++;
|
|
131
|
+
if (cursor > numberStart && !(cursor === numberStart + 1 && raw[numberStart] === '-')) {
|
|
132
|
+
const digits = raw.slice(numberStart, cursor);
|
|
133
|
+
// Prevent invalid JSON numbers (e.g. 01) from becoming valid quoted strings.
|
|
134
|
+
if (!/^-?(?:0|[1-9]\d*)$/u.test(digits))
|
|
135
|
+
invalid();
|
|
136
|
+
output += `"${digits}"`;
|
|
137
|
+
index = cursor;
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
index = numberStart;
|
|
141
|
+
}
|
|
142
|
+
return JSON.parse(output);
|
|
143
|
+
}
|
|
144
|
+
async function readJson(response, signal) {
|
|
145
|
+
const length = response.headers.get('content-length');
|
|
146
|
+
if (length && (!/^\d+$/u.test(length) || Number(length) > MAX_RESPONSE_BYTES)) {
|
|
147
|
+
await response.body?.cancel();
|
|
148
|
+
throw new ILinkError('clawbot-response-too-large', '微信响应超过本地处理上限');
|
|
149
|
+
}
|
|
150
|
+
if (!response.body)
|
|
151
|
+
invalid();
|
|
152
|
+
const reader = response.body.getReader();
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let size = 0;
|
|
155
|
+
const onAbort = () => { void reader.cancel().catch(() => { }); };
|
|
156
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
157
|
+
try {
|
|
158
|
+
while (true) {
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
const { done, value } = await reader.read();
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
if (done)
|
|
163
|
+
break;
|
|
164
|
+
size += value.byteLength;
|
|
165
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
166
|
+
await reader.cancel();
|
|
167
|
+
throw new ILinkError('clawbot-response-too-large', '微信响应超过本地处理上限');
|
|
168
|
+
}
|
|
169
|
+
chunks.push(value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
signal.removeEventListener('abort', onAbort);
|
|
174
|
+
reader.releaseLock();
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
178
|
+
const result = parseJson(decoded);
|
|
179
|
+
if (!object(result))
|
|
180
|
+
invalid();
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof ILinkError)
|
|
185
|
+
throw error;
|
|
186
|
+
invalid();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Inline quotes are context, not a new command or an invitation to fetch history. */
|
|
190
|
+
function inlineQuote(items) {
|
|
191
|
+
const ref = items.find((item) => object(item.ref_msg))?.ref_msg;
|
|
192
|
+
if (!object(ref))
|
|
193
|
+
return '';
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (boundedString(ref.title, 1024))
|
|
196
|
+
parts.push(ref.title);
|
|
197
|
+
if (object(ref.message_item)) {
|
|
198
|
+
const item = ref.message_item;
|
|
199
|
+
const body = item.type === 1 && object(item.text_item) ? item.text_item.text
|
|
200
|
+
: item.type === 3 && object(item.voice_item) ? item.voice_item.text : undefined;
|
|
201
|
+
if (boundedString(body, 16 * 1024))
|
|
202
|
+
parts.push(body);
|
|
203
|
+
else {
|
|
204
|
+
const labels = { 2: '[图片]', 3: '[语音]', 4: '[文件]', 5: '[视频]' };
|
|
205
|
+
if (labels[item.type])
|
|
206
|
+
parts.push(labels[item.type]);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// One level only. Do not replay quoted slash commands, resolve IDs from another
|
|
210
|
+
// account, follow referenced media URLs, or fabricate missing cached content.
|
|
211
|
+
return '[引用消息]\n' + (parts.length ? parts.join('\n') : '[引用内容不可用]') + '\n[/引用消息]\n';
|
|
212
|
+
}
|
|
213
|
+
function normalizeMessage(value) {
|
|
214
|
+
if (!object(value))
|
|
215
|
+
invalid();
|
|
216
|
+
if (value.group_id !== undefined && value.group_id !== null && typeof value.group_id !== 'string')
|
|
217
|
+
invalid();
|
|
218
|
+
// Explicitly exclude group events and bot echoes; never execute them as a direct user command.
|
|
219
|
+
if ((typeof value.group_id === 'string' && value.group_id !== '') || value.message_type === 2)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (value.message_type !== 1 || !privateUser(value.from_user_id) || !secret(value.context_token))
|
|
222
|
+
invalid();
|
|
223
|
+
if (!Array.isArray(value.item_list) || value.item_list.length === 0 || value.item_list.length > 100)
|
|
224
|
+
invalid();
|
|
225
|
+
const items = value.item_list;
|
|
226
|
+
if (items.some((item) => !object(item)))
|
|
227
|
+
invalid();
|
|
228
|
+
const id = value.message_id ?? items.find((item) => messageId(item.msg_id))?.msg_id;
|
|
229
|
+
if (!messageId(id))
|
|
230
|
+
invalid('微信消息缺少有效标识,已停止处理此批消息');
|
|
231
|
+
const text = [];
|
|
232
|
+
const media = [];
|
|
233
|
+
let unsupported = false;
|
|
234
|
+
let unsupportedReason;
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
if (typeof item.type !== 'number' || !Number.isSafeInteger(item.type))
|
|
237
|
+
invalid();
|
|
238
|
+
if (item.type === 1) {
|
|
239
|
+
if (!object(item.text_item) || !boundedString(item.text_item.text, MAX_TEXT_BYTES, true))
|
|
240
|
+
invalid();
|
|
241
|
+
text.push(item.text_item.text);
|
|
242
|
+
}
|
|
243
|
+
else if (item.type === 3 && object(item.voice_item) && typeof item.voice_item.text === 'string' && item.voice_item.text.trim()) {
|
|
244
|
+
// Tencent already transcribed this voice item. Do not fetch/decrypt it or
|
|
245
|
+
// claim that the plugin itself can transcribe raw audio.
|
|
246
|
+
if (!boundedString(item.voice_item.text, MAX_TEXT_BYTES))
|
|
247
|
+
invalid();
|
|
248
|
+
text.push(item.voice_item.text);
|
|
249
|
+
}
|
|
250
|
+
else if ([2, 3, 4, 5].includes(item.type)) {
|
|
251
|
+
try {
|
|
252
|
+
media.push(mediaFromItem(item));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
unsupported = true;
|
|
256
|
+
unsupportedReason = error instanceof ILinkError ? error.message : '微信附件暂不支持,未发送到会话';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
unsupported = true;
|
|
261
|
+
unsupportedReason = '这条消息包含暂不支持的微信内容,整条消息未发送到会话。请改用文字或在小程序、电脑上传附件。';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const directText = text.join('\n');
|
|
265
|
+
// Explicit channel controls keep their meaning when the user quotes a
|
|
266
|
+
// message. Quoted commands themselves never acquire execution authority.
|
|
267
|
+
const joined = !media.length && directText.trim().startsWith('/') ? directText : inlineQuote(items) + directText;
|
|
268
|
+
if (!boundedString(joined, MAX_TEXT_BYTES, true))
|
|
269
|
+
invalid();
|
|
270
|
+
if (media.length > MEDIA_MAX_ITEMS || media.reduce((sum, item) => sum + (item.bytes || 0), 0) > 30 * 1024 * 1024) {
|
|
271
|
+
unsupported = true;
|
|
272
|
+
unsupportedReason = '每条消息最多接收 6 个附件、合计 30 MiB,整条消息未发送到会话';
|
|
273
|
+
}
|
|
274
|
+
if (!directText.trim() && !media.length)
|
|
275
|
+
unsupported = true;
|
|
276
|
+
return { id, userId: value.from_user_id, contextToken: value.context_token, text: joined,
|
|
277
|
+
...(unsupported ? { unsupported: true, ...(unsupportedReason ? { unsupportedReason } : {}) }
|
|
278
|
+
: media.length ? { media } : {}) };
|
|
279
|
+
}
|
|
280
|
+
export class ILinkClient {
|
|
281
|
+
fetchImpl;
|
|
282
|
+
qrStates = new Map();
|
|
283
|
+
// Only a timeout hint is retained; credentials, user content and verification codes are not cached.
|
|
284
|
+
nextPollTimeout = POLL_TIMEOUT_MS;
|
|
285
|
+
constructor(options = {}) {
|
|
286
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
287
|
+
}
|
|
288
|
+
/** Provider-only download; no token forwarding, filesystem writes or retries. */
|
|
289
|
+
downloadMedia(media, signal) {
|
|
290
|
+
return downloadMedia(media, this.fetchImpl, signal);
|
|
291
|
+
}
|
|
292
|
+
/** Upload only; runtime records the result before a separate, single send. */
|
|
293
|
+
async prepareMedia(auth, input, signal, check) {
|
|
294
|
+
const snapshot = { ...auth }, baseUrl = credentials(snapshot);
|
|
295
|
+
return prepareMedia(snapshot, input, this.fetchImpl, (body) => this.request(baseUrl, 'ilink/bot/getuploadurl', {
|
|
296
|
+
token: snapshot.token, signal, body: { ...body, base_info: { ...BASE_INFO } },
|
|
297
|
+
}), signal, check);
|
|
298
|
+
}
|
|
299
|
+
async sendMedia(auth, input, signal) {
|
|
300
|
+
const baseUrl = credentials(auth);
|
|
301
|
+
if (!object(input) || !privateUser(input.userId) || input.userId !== auth.userId
|
|
302
|
+
|| !identifier(input.clientId) || !secret(input.contextToken) || !validatePreparedMedia(input.media)
|
|
303
|
+
|| input.media.accountId !== auth.accountId || input.media.userId !== input.userId) {
|
|
304
|
+
throw new ILinkError('clawbot-invalid-input', '微信附件缺少有效收件人、上下文或上传结果');
|
|
305
|
+
}
|
|
306
|
+
const part = input.media;
|
|
307
|
+
const media = { encrypt_query_param: part.query, aes_key: part.aesKey, encrypt_type: 1 };
|
|
308
|
+
const item = part.kind === 'image' ? { type: 2, image_item: { media, mid_size: part.cipherBytes } }
|
|
309
|
+
: part.kind === 'video' ? { type: 5, video_item: { media, video_size: part.cipherBytes } }
|
|
310
|
+
: { type: 4, file_item: { media, file_name: part.name, len: String(part.bytes) } };
|
|
311
|
+
// Exactly one item/request, just like native media sends. Captions are a
|
|
312
|
+
// separate text part owned by the runtime's durable, non-retrying outbox.
|
|
313
|
+
await this.request(baseUrl, 'ilink/bot/sendmessage', { token: auth.token, signal,
|
|
314
|
+
body: { msg: { from_user_id: '', to_user_id: input.userId, client_id: input.clientId,
|
|
315
|
+
message_type: 2, message_state: 2, context_token: input.contextToken, item_list: [item] }, base_info: { ...BASE_INFO } },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
async request(baseUrl, endpoint, options) {
|
|
319
|
+
options.signal?.throwIfAborted();
|
|
320
|
+
const url = new URL(endpoint, `${safeUrl(baseUrl, 'api')}/`).href;
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
323
|
+
options.signal?.addEventListener('abort', forwardAbort, { once: true });
|
|
324
|
+
let timedOut = false;
|
|
325
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? API_TIMEOUT_MS);
|
|
326
|
+
const headers = {
|
|
327
|
+
'iLink-App-Id': 'bot',
|
|
328
|
+
'iLink-App-ClientVersion': CLIENT_VERSION,
|
|
329
|
+
};
|
|
330
|
+
if (options.body !== undefined) {
|
|
331
|
+
headers['Content-Type'] = 'application/json';
|
|
332
|
+
headers.AuthorizationType = 'ilink_bot_token';
|
|
333
|
+
headers['X-WECHAT-UIN'] = Buffer.from(String(randomBytes(4).readUInt32BE()), 'utf8').toString('base64');
|
|
334
|
+
if (options.token)
|
|
335
|
+
headers.Authorization = `Bearer ${options.token}`;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const response = await this.fetchImpl(url, { method: options.body === undefined ? 'GET' : 'POST',
|
|
339
|
+
headers, ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
340
|
+
redirect: 'error', signal: controller.signal });
|
|
341
|
+
controller.signal.throwIfAborted();
|
|
342
|
+
if (response.redirected || (response.status >= 300 && response.status < 400)
|
|
343
|
+
|| (response.url && response.url !== url)) {
|
|
344
|
+
await response.body?.cancel();
|
|
345
|
+
throw new ILinkError('clawbot-untrusted-url', '微信接口发生了不允许的跳转');
|
|
346
|
+
}
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
await response.body?.cancel();
|
|
349
|
+
throw new ILinkError(response.status === 401 || response.status === 403
|
|
350
|
+
? 'clawbot-auth-expired' : 'clawbot-http-error', '微信接口请求失败', undefined, response.status);
|
|
351
|
+
}
|
|
352
|
+
const body = await readJson(response, controller.signal);
|
|
353
|
+
checkApiResult(body);
|
|
354
|
+
return body;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
options.signal?.throwIfAborted();
|
|
358
|
+
if (timedOut)
|
|
359
|
+
throw new ILinkError('clawbot-timeout', '等待微信响应超时');
|
|
360
|
+
if (error instanceof ILinkError)
|
|
361
|
+
throw error;
|
|
362
|
+
throw new ILinkError('clawbot-network-error', '暂时无法连接微信,请稍后重试');
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
options.signal?.removeEventListener('abort', forwardAbort);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async getQr(signal, localTokens = []) {
|
|
370
|
+
if (!Array.isArray(localTokens))
|
|
371
|
+
throw new ILinkError('clawbot-invalid-input', '本地微信凭据列表无效');
|
|
372
|
+
// Caller supplies only this node's tokens, oldest first. Match upstream's
|
|
373
|
+
// most-recent-first list, bounded to ten; never inspect another application's store.
|
|
374
|
+
const recentTokens = [];
|
|
375
|
+
for (let index = localTokens.length - 1; index >= 0 && recentTokens.length < 10; index--) {
|
|
376
|
+
const token = typeof localTokens[index] === 'string' ? localTokens[index].trim() : '';
|
|
377
|
+
if (secret(token))
|
|
378
|
+
recentTokens.push(token);
|
|
379
|
+
}
|
|
380
|
+
const body = await this.request(API_BASE, 'ilink/bot/get_bot_qrcode?bot_type=3', {
|
|
381
|
+
body: { local_token_list: recentTokens }, signal,
|
|
382
|
+
});
|
|
383
|
+
if (!secret(body.qrcode))
|
|
384
|
+
invalid();
|
|
385
|
+
const url = safeUrl(body.qrcode_img_content, 'qr');
|
|
386
|
+
// Bound abandoned local login state; this is not the provider's QR expiration policy.
|
|
387
|
+
if (this.qrStates.size >= MAX_QR_SESSIONS)
|
|
388
|
+
this.qrStates.delete(this.qrStates.keys().next().value);
|
|
389
|
+
this.qrStates.set(body.qrcode, { baseUrl: API_BASE, redirects: 0 });
|
|
390
|
+
return { qrcode: body.qrcode, url };
|
|
391
|
+
}
|
|
392
|
+
async pollQr(qrcode, signal, verifyCode) {
|
|
393
|
+
if (!secret(qrcode) || (verifyCode !== undefined && !/^\d{1,16}$/u.test(verifyCode))) {
|
|
394
|
+
throw new ILinkError('clawbot-invalid-input', '微信连接参数不完整或验证码格式不正确');
|
|
395
|
+
}
|
|
396
|
+
const state = this.qrStates.get(qrcode);
|
|
397
|
+
if (!state)
|
|
398
|
+
throw new ILinkError('clawbot-invalid-input', '请重新获取微信连接二维码');
|
|
399
|
+
let body;
|
|
400
|
+
try {
|
|
401
|
+
const query = `qrcode=${encodeURIComponent(qrcode)}`
|
|
402
|
+
+ (verifyCode === undefined ? '' : `&verify_code=${encodeURIComponent(verifyCode)}`);
|
|
403
|
+
body = await this.request(state.baseUrl, `ilink/bot/get_qrcode_status?${query}`, {
|
|
404
|
+
signal, timeoutMs: POLL_TIMEOUT_MS,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (error instanceof ILinkError && error.code === 'clawbot-timeout')
|
|
409
|
+
return { status: 'wait' };
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
const status = body.status;
|
|
413
|
+
if (status === 'scaned_but_redirect') {
|
|
414
|
+
if (!boundedString(body.redirect_host, 253) || !/^[a-zA-Z0-9.-]+$/u.test(body.redirect_host))
|
|
415
|
+
invalid();
|
|
416
|
+
const baseUrl = safeUrl(`https://${body.redirect_host}`, 'api');
|
|
417
|
+
if (++state.redirects > MAX_QR_REDIRECTS)
|
|
418
|
+
invalid('微信连接跳转次数超过本地上限');
|
|
419
|
+
state.baseUrl = baseUrl;
|
|
420
|
+
return { status, message: '正在继续微信确认流程' };
|
|
421
|
+
}
|
|
422
|
+
if (status === 'confirmed') {
|
|
423
|
+
if (!secret(body.bot_token) || !identifier(body.ilink_bot_id) || !privateUser(body.ilink_user_id))
|
|
424
|
+
invalid();
|
|
425
|
+
const result = { token: body.bot_token, accountId: body.ilink_bot_id,
|
|
426
|
+
userId: body.ilink_user_id, baseUrl: safeUrl(body.baseurl ?? state.baseUrl, 'api') };
|
|
427
|
+
this.qrStates.delete(qrcode);
|
|
428
|
+
return { status, credentials: result };
|
|
429
|
+
}
|
|
430
|
+
const messages = {
|
|
431
|
+
wait: '请用手机微信扫描二维码并确认连接',
|
|
432
|
+
scaned: '请继续完成手机微信中的确认',
|
|
433
|
+
need_verifycode: '请输入手机微信显示的数字验证码',
|
|
434
|
+
verify_code_blocked: '验证码尝试次数过多,请重新获取二维码',
|
|
435
|
+
expired: '二维码已失效,请重新获取',
|
|
436
|
+
binded_redirect: '微信提示已连接,请使用已有连接或重新发起连接',
|
|
437
|
+
};
|
|
438
|
+
if (typeof status !== 'string' || !Object.hasOwn(messages, status))
|
|
439
|
+
invalid('微信返回了未知的连接状态');
|
|
440
|
+
if (['expired', 'verify_code_blocked', 'binded_redirect'].includes(status))
|
|
441
|
+
this.qrStates.delete(qrcode);
|
|
442
|
+
return { status, message: messages[status] };
|
|
443
|
+
}
|
|
444
|
+
/** Forget an ended local attempt; never calls an account-unbind endpoint. */
|
|
445
|
+
forgetQr(qrcode) { this.qrStates.delete(qrcode); }
|
|
446
|
+
async getUpdates(auth, cursor, signal) {
|
|
447
|
+
const baseUrl = credentials(auth);
|
|
448
|
+
if (!boundedString(cursor, MAX_CURSOR_BYTES, true))
|
|
449
|
+
throw new ILinkError('clawbot-invalid-input', '微信消息游标无效');
|
|
450
|
+
let body;
|
|
451
|
+
try {
|
|
452
|
+
body = await this.request(baseUrl, 'ilink/bot/getupdates', { body: {
|
|
453
|
+
get_updates_buf: cursor, base_info: { ...BASE_INFO },
|
|
454
|
+
}, token: auth.token, signal, timeoutMs: this.nextPollTimeout });
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (error instanceof ILinkError && error.code === 'clawbot-timeout')
|
|
458
|
+
return { cursor, messages: [] };
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
if (body.get_updates_buf !== undefined && !boundedString(body.get_updates_buf, MAX_CURSOR_BYTES, true))
|
|
462
|
+
invalid();
|
|
463
|
+
if (body.msgs !== undefined && (!Array.isArray(body.msgs) || body.msgs.length > MAX_MESSAGES))
|
|
464
|
+
invalid('微信消息批次超过本地处理上限');
|
|
465
|
+
const hint = body.longpolling_timeout_ms;
|
|
466
|
+
if (hint !== undefined) {
|
|
467
|
+
if (typeof hint !== 'number' || !Number.isSafeInteger(hint) || hint <= 0)
|
|
468
|
+
invalid();
|
|
469
|
+
this.nextPollTimeout = Math.min(hint, MAX_POLL_TIMEOUT_MS);
|
|
470
|
+
}
|
|
471
|
+
// Validate the entire batch before returning its cursor; never silently truncate it.
|
|
472
|
+
const messages = (body.msgs ?? []).map(normalizeMessage)
|
|
473
|
+
.filter((message) => message !== undefined);
|
|
474
|
+
return { cursor: body.get_updates_buf ? body.get_updates_buf : cursor, messages };
|
|
475
|
+
}
|
|
476
|
+
async sendText(auth, text, signal) {
|
|
477
|
+
const baseUrl = credentials(auth);
|
|
478
|
+
if (!object(text) || !privateUser(text.userId) || !identifier(text.clientId)
|
|
479
|
+
|| !secret(text.contextToken) || !boundedString(text.text, MAX_TEXT_BYTES, true) || text.text.length === 0) {
|
|
480
|
+
throw new ILinkError('clawbot-invalid-input', '微信消息缺少收件人、上下文或有效文本');
|
|
481
|
+
}
|
|
482
|
+
// A failed/ambiguous send is returned to the caller; transport never retries a write.
|
|
483
|
+
await this.request(baseUrl, 'ilink/bot/sendmessage', { token: auth.token, signal,
|
|
484
|
+
body: { msg: { from_user_id: '', to_user_id: text.userId, client_id: text.clientId,
|
|
485
|
+
message_type: 2, message_state: 2, context_token: text.contextToken,
|
|
486
|
+
item_list: [{ type: 1, text_item: { text: text.text } }] }, base_info: { ...BASE_INFO } },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Acquire only the current host's owner Peer; no peer identity comes from mobile RPC. */
|
|
2
|
+
export async function withNativePeer(ctx, signal, run, borrowed) {
|
|
3
|
+
signal?.throwIfAborted();
|
|
4
|
+
const connection = ctx?.get?.('connection');
|
|
5
|
+
const peer = borrowed ?? (typeof connection?.createPeer === 'function'
|
|
6
|
+
? connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' }) : undefined);
|
|
7
|
+
if (!peer) {
|
|
8
|
+
if (typeof connection?.createPeer === 'function') throw new Error('DSH Connection returned no Peer');
|
|
9
|
+
return run({ signal, dispose: async () => {} });
|
|
10
|
+
}
|
|
11
|
+
if (!peer.signal || typeof peer.signal.throwIfAborted !== 'function' || typeof peer.dispose !== 'function') {
|
|
12
|
+
throw new Error('DSH Connection returned an invalid Peer');
|
|
13
|
+
}
|
|
14
|
+
let disposal;
|
|
15
|
+
const dispose = () => {
|
|
16
|
+
signal?.removeEventListener('abort', abort);
|
|
17
|
+
return disposal ??= borrowed ? Promise.resolve() : Promise.resolve().then(() => peer.dispose()).catch(error => {
|
|
18
|
+
console.warn('[weapp-gateway] ClawBot Peer cleanup failed:', error);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
const abort = () => { void dispose(); };
|
|
22
|
+
if (!borrowed) signal?.addEventListener('abort', abort, { once: true });
|
|
23
|
+
if (signal?.aborted || peer.signal.aborted) {
|
|
24
|
+
void dispose();
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
peer.signal.throwIfAborted();
|
|
27
|
+
}
|
|
28
|
+
return await run({ peer, signal: signal ? AbortSignal.any([signal, peer.signal]) : peer.signal, dispose });
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Local resource budgets, not Weixin service limits. Keep the full native
|
|
2
|
+
* response within the host's 256 KiB budget; transport chunks never trim it. */
|
|
3
|
+
export const REPLY_TEXT_MAX_BYTES = 256 * 1024;
|
|
4
|
+
export const TEXT_PART_MAX_BYTES = 1800;
|
|
5
|
+
export const REPLY_NOTICE_MAX_BYTES = 2048;
|
|
6
|
+
export const REPLY_MAX_NOTICES = 8;
|
|
7
|
+
// A UTF-8 codepoint can leave at most three unused bytes in a full chunk.
|
|
8
|
+
export const REPLY_MAX_TEXT_PARTS = Math.ceil(REPLY_TEXT_MAX_BYTES / (TEXT_PART_MAX_BYTES - 3))
|
|
9
|
+
+ REPLY_MAX_NOTICES * Math.ceil(REPLY_NOTICE_MAX_BYTES / (TEXT_PART_MAX_BYTES - 3));
|
|
10
|
+
export const REPLY_TOTAL_TEXT_MAX_BYTES = REPLY_TEXT_MAX_BYTES + REPLY_MAX_NOTICES * REPLY_NOTICE_MAX_BYTES;
|
|
11
|
+
/** Preserve every character, including leading/trailing whitespace and emoji
|
|
12
|
+
* surrogate pairs. Empty native output creates no text request or placeholder. */
|
|
13
|
+
export function splitText(text) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
let part = '', bytes = 0;
|
|
16
|
+
for (const char of text) {
|
|
17
|
+
const size = Buffer.byteLength(char);
|
|
18
|
+
if (bytes + size > TEXT_PART_MAX_BYTES) {
|
|
19
|
+
parts.push(part);
|
|
20
|
+
part = '';
|
|
21
|
+
bytes = 0;
|
|
22
|
+
}
|
|
23
|
+
part += char;
|
|
24
|
+
bytes += size;
|
|
25
|
+
}
|
|
26
|
+
if (part)
|
|
27
|
+
parts.push(part);
|
|
28
|
+
return parts;
|
|
29
|
+
}
|