@xmanrui/dsh-im 1.0.2 → 1.2.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/README.en.md +23 -3
- package/README.md +23 -3
- package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
- package/assets/logo_cn.png +0 -0
- package/lib/client.js +815 -560
- package/lib/index.js +163 -163
- package/package.json +1 -1
- package/plugin-src/client/agent-preset.js +15 -6
- package/plugin-src/client/channel-card-meta.js +48 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -19
- package/plugin-src/client/channels/dingtalk/styles.js +0 -6
- package/plugin-src/client/channels/feishu/index.js +41 -35
- package/plugin-src/client/channels/feishu/styles.js +0 -5
- package/plugin-src/client/channels/qq/index.js +24 -16
- package/plugin-src/client/channels/shared/token-channel.js +32 -24
- package/plugin-src/client/channels/wecom/index.js +24 -16
- package/plugin-src/client/channels/weixin/index.js +29 -23
- package/plugin-src/client/channels/weixin/styles.js +0 -5
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +152 -23
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +20 -0
- package/plugin-src/client/styles.js +23 -8
- package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
- package/plugin-src/host/index.mjs +14 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
- package/src/channels/discord/discord-api.mjs +134 -6
- package/src/channels/discord/discord-runtime.mjs +15 -4
- package/src/channels/feishu/bridge.mjs +223 -15
- package/src/channels/feishu/feishu-channel.mjs +227 -1
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +217 -10
- package/src/channels/shared/editable-message-stream.mjs +18 -1
- package/src/channels/shared/harness-client.mjs +99 -7
- package/src/channels/shared/semantic/artifact.mjs +748 -0
- package/src/channels/shared/semantic/delivery.mjs +153 -0
- package/src/channels/shared/text-harness-bridge.mjs +149 -3
- package/src/channels/shared/workspace-session.mjs +15 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +167 -4
- package/src/channels/slack/slack-runtime.mjs +21 -5
- package/src/channels/telegram/telegram-api.mjs +111 -5
- package/src/channels/telegram/telegram-runtime.mjs +18 -4
- package/src/channels/wecom/wecom-bridge.mjs +260 -12
- package/src/channels/weixin/weixin-api.mjs +268 -2
- package/src/channels/weixin/weixin-bridge.mjs +134 -3
- package/src/channels/weixin/weixin-controller.mjs +5 -1
- package/src/channels/weixin/weixin-runtime.mjs +5 -1
- package/src/channels/whatsapp/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
randomUUID,
|
|
7
|
+
} from 'node:crypto';
|
|
2
8
|
|
|
3
9
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
4
10
|
|
|
@@ -13,6 +19,7 @@ const ILINK_APP_ID = 'bot';
|
|
|
13
19
|
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
14
20
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
15
21
|
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
22
|
+
const WEIXIN_CDN_UPLOAD_RETRIES = 3;
|
|
16
23
|
const LOGIN_STATUSES = new Set([
|
|
17
24
|
'wait',
|
|
18
25
|
'scaned',
|
|
@@ -30,6 +37,7 @@ export class WeixinApiError extends Error {
|
|
|
30
37
|
this.name = 'WeixinApiError';
|
|
31
38
|
this.code = code;
|
|
32
39
|
this.status = options.status;
|
|
40
|
+
this.providerCode = options.providerCode;
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -37,6 +45,70 @@ function nonEmptyString(value) {
|
|
|
37
45
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
function safeProviderCode(value) {
|
|
49
|
+
const code = value === undefined || value === null ? null : String(value).trim();
|
|
50
|
+
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function preserveArtifactMetadata(target, source) {
|
|
54
|
+
if (Number.isInteger(source?.status)) target.status = source.status;
|
|
55
|
+
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
|
|
60
|
+
if (cause?.code?.startsWith?.('artifact-')) return cause;
|
|
61
|
+
const status = Number(cause?.status);
|
|
62
|
+
const providerCode = safeProviderCode(cause?.providerCode);
|
|
63
|
+
const providerText = providerCode ?? '';
|
|
64
|
+
let code = fallback;
|
|
65
|
+
let message = 'Weixin could not prepare the file for delivery.';
|
|
66
|
+
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|
|
67
|
+
|| /(?:permission|forbidden|unauthor|access.?denied)/i.test(providerText)) {
|
|
68
|
+
code = 'artifact-permission-required';
|
|
69
|
+
message = 'Weixin denied permission to send the file.';
|
|
70
|
+
} else if (status === 413 || providerCode === '413'
|
|
71
|
+
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
|
|
72
|
+
code = 'artifact-too-large';
|
|
73
|
+
message = 'The file exceeds Weixin\'s size limit.';
|
|
74
|
+
} else if (status === 429 || providerCode === '429'
|
|
75
|
+
|| /(?:rate.?limit|too.?many)/i.test(providerText)) {
|
|
76
|
+
code = 'artifact-rate-limited';
|
|
77
|
+
message = 'Weixin rate-limited file delivery.';
|
|
78
|
+
} else if (fallback === 'artifact-provider-rejected') {
|
|
79
|
+
message = 'Weixin rejected the file message.';
|
|
80
|
+
}
|
|
81
|
+
const error = new Error(message, { cause });
|
|
82
|
+
error.code = code;
|
|
83
|
+
return preserveArtifactMetadata(error, cause);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function uncertainWeixinDelivery(cause) {
|
|
87
|
+
const error = new Error('Weixin file delivery result is uncertain', { cause });
|
|
88
|
+
error.code = 'artifact-delivery-uncertain';
|
|
89
|
+
return preserveArtifactMetadata(error, cause);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rejectedProviderResponse(value) {
|
|
93
|
+
if (!value || typeof value !== 'object') return null;
|
|
94
|
+
for (const field of ['ret', 'errcode']) {
|
|
95
|
+
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
|
|
96
|
+
return safeProviderCode(value[field]) ?? 'rejected';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function classifyWeixinFinalDeliveryError(error, signal) {
|
|
103
|
+
if (signal?.aborted) throw abortError(signal);
|
|
104
|
+
const status = Number(error?.status);
|
|
105
|
+
if (error?.code === 'network-error' || error?.code === 'timeout'
|
|
106
|
+
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
|
|
107
|
+
return uncertainWeixinDelivery(error);
|
|
108
|
+
}
|
|
109
|
+
return weixinArtifactError(error);
|
|
110
|
+
}
|
|
111
|
+
|
|
40
112
|
function strictBase64(value) {
|
|
41
113
|
const text = nonEmptyString(value);
|
|
42
114
|
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
|
|
@@ -184,10 +256,93 @@ function authenticatedHeaders(token) {
|
|
|
184
256
|
function baseInfo() {
|
|
185
257
|
return {
|
|
186
258
|
channel_version: WEIXIN_PROTOCOL_VERSION,
|
|
187
|
-
bot_agent: 'DeepSeekHarness/1.0
|
|
259
|
+
bot_agent: 'DeepSeekHarness/1.1.0',
|
|
188
260
|
};
|
|
189
261
|
}
|
|
190
262
|
|
|
263
|
+
function aesEcbPaddedSize(size) {
|
|
264
|
+
return Math.ceil((size + 1) / 16) * 16;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function trustedWeixinCdnUploadUrl(value) {
|
|
268
|
+
let url;
|
|
269
|
+
try {
|
|
270
|
+
url = new URL(value);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new WeixinApiError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
|
|
273
|
+
}
|
|
274
|
+
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|
|
275
|
+
|| (url.port && url.port !== '443') || url.pathname !== '/c2c/upload'
|
|
276
|
+
|| url.username || url.password) {
|
|
277
|
+
throw new WeixinApiError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
|
|
278
|
+
}
|
|
279
|
+
url.hash = '';
|
|
280
|
+
return url;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function weixinCdnUploadUrl(response, fileKey) {
|
|
284
|
+
const fullUrl = nonEmptyString(response?.upload_full_url);
|
|
285
|
+
if (fullUrl) return trustedWeixinCdnUploadUrl(fullUrl);
|
|
286
|
+
const uploadParam = nonEmptyString(response?.upload_param);
|
|
287
|
+
if (!uploadParam) {
|
|
288
|
+
throw new WeixinApiError('missing-upload-url', '微信服务没有返回文件上传地址。');
|
|
289
|
+
}
|
|
290
|
+
const url = new URL(`${WEIXIN_CDN_BASE_URL}/upload`);
|
|
291
|
+
url.searchParams.set('encrypted_query_param', uploadParam);
|
|
292
|
+
url.searchParams.set('filekey', fileKey);
|
|
293
|
+
return trustedWeixinCdnUploadUrl(url);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function encryptWeixinUpload(bytes, key) {
|
|
297
|
+
const cipher = createCipheriv('aes-128-ecb', key, null);
|
|
298
|
+
return Buffer.concat([cipher.update(bytes), cipher.final()]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
|
|
302
|
+
let lastError;
|
|
303
|
+
for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
|
|
304
|
+
signal?.throwIfAborted();
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetchImpl(url, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
309
|
+
body: ciphertext,
|
|
310
|
+
signal: signal
|
|
311
|
+
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
|
312
|
+
: AbortSignal.timeout(60_000),
|
|
313
|
+
redirect: 'error',
|
|
314
|
+
});
|
|
315
|
+
if (response.status >= 400 && response.status < 500) {
|
|
316
|
+
throw new WeixinApiError(
|
|
317
|
+
'upload-rejected',
|
|
318
|
+
`微信文件上传被拒绝(HTTP ${response.status})。`,
|
|
319
|
+
{ status: response.status },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (response.status !== 200) {
|
|
323
|
+
throw new WeixinApiError(
|
|
324
|
+
'upload-failed',
|
|
325
|
+
`微信文件上传失败(HTTP ${response.status})。`,
|
|
326
|
+
{ status: response.status },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
|
|
330
|
+
await response.body?.cancel?.().catch(() => undefined);
|
|
331
|
+
if (!downloadParam) {
|
|
332
|
+
throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
|
|
333
|
+
}
|
|
334
|
+
return downloadParam;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (signal?.aborted) throw abortError(signal);
|
|
337
|
+
if (error instanceof WeixinApiError
|
|
338
|
+
&& (error.code === 'upload-rejected' || error.status < 500)) throw error;
|
|
339
|
+
lastError = error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (lastError instanceof WeixinApiError) throw lastError;
|
|
343
|
+
throw new WeixinApiError('upload-failed', '微信文件上传失败。', { cause: lastError });
|
|
344
|
+
}
|
|
345
|
+
|
|
191
346
|
function abortError(signal) {
|
|
192
347
|
if (signal?.reason instanceof Error) return signal.reason;
|
|
193
348
|
return new DOMException('The operation was aborted', 'AbortError');
|
|
@@ -349,6 +504,117 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
349
504
|
return true;
|
|
350
505
|
},
|
|
351
506
|
|
|
507
|
+
async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
|
|
508
|
+
const recipient = nonEmptyString(toUserId);
|
|
509
|
+
if (!recipient || !file || typeof file !== 'object'
|
|
510
|
+
|| typeof file.fileName !== 'string' || !file.fileName
|
|
511
|
+
|| !Buffer.isBuffer(file.bytes)) {
|
|
512
|
+
throw new TypeError('toUserId and a file are required');
|
|
513
|
+
}
|
|
514
|
+
signal?.throwIfAborted();
|
|
515
|
+
const fileKey = randomBytes(16).toString('hex');
|
|
516
|
+
const aesKey = randomBytes(16);
|
|
517
|
+
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
|
518
|
+
let upload;
|
|
519
|
+
try {
|
|
520
|
+
upload = await requestJson(fetchImpl, {
|
|
521
|
+
method: 'POST',
|
|
522
|
+
baseUrl,
|
|
523
|
+
endpoint: 'ilink/bot/getuploadurl',
|
|
524
|
+
token,
|
|
525
|
+
signal,
|
|
526
|
+
body: {
|
|
527
|
+
filekey: fileKey,
|
|
528
|
+
media_type: 3,
|
|
529
|
+
to_user_id: recipient,
|
|
530
|
+
rawsize: file.bytes.byteLength,
|
|
531
|
+
rawfilemd5: rawMd5,
|
|
532
|
+
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
|
533
|
+
no_need_thumb: true,
|
|
534
|
+
aeskey: aesKey.toString('hex'),
|
|
535
|
+
base_info: baseInfo(),
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
} catch (error) {
|
|
539
|
+
if (signal?.aborted) throw abortError(signal);
|
|
540
|
+
const status = Number(error?.status);
|
|
541
|
+
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
|
542
|
+
? 'artifact-provider-rejected'
|
|
543
|
+
: 'artifact-provider-failed';
|
|
544
|
+
throw weixinArtifactError(error, { fallback });
|
|
545
|
+
}
|
|
546
|
+
const uploadRejection = rejectedProviderResponse(upload);
|
|
547
|
+
if (uploadRejection) {
|
|
548
|
+
throw weixinArtifactError(new WeixinApiError(
|
|
549
|
+
'upload-url-rejected',
|
|
550
|
+
'微信服务拒绝了文件上传请求。',
|
|
551
|
+
{ providerCode: uploadRejection },
|
|
552
|
+
));
|
|
553
|
+
}
|
|
554
|
+
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
|
555
|
+
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
|
556
|
+
let downloadParam;
|
|
557
|
+
try {
|
|
558
|
+
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (signal?.aborted) throw abortError(signal);
|
|
561
|
+
const status = Number(error?.status);
|
|
562
|
+
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
|
563
|
+
? 'artifact-provider-rejected'
|
|
564
|
+
: 'artifact-provider-failed';
|
|
565
|
+
throw weixinArtifactError(error, { fallback });
|
|
566
|
+
}
|
|
567
|
+
signal?.throwIfAborted();
|
|
568
|
+
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
|
569
|
+
?? randomUUID();
|
|
570
|
+
const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
|
|
571
|
+
let response;
|
|
572
|
+
try {
|
|
573
|
+
response = await requestJson(fetchImpl, {
|
|
574
|
+
method: 'POST',
|
|
575
|
+
baseUrl,
|
|
576
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
577
|
+
token,
|
|
578
|
+
signal,
|
|
579
|
+
body: {
|
|
580
|
+
msg: {
|
|
581
|
+
from_user_id: '',
|
|
582
|
+
to_user_id: recipient,
|
|
583
|
+
client_id: clientId,
|
|
584
|
+
message_type: 2,
|
|
585
|
+
message_state: 2,
|
|
586
|
+
item_list: [{
|
|
587
|
+
type: 4,
|
|
588
|
+
file_item: {
|
|
589
|
+
media: {
|
|
590
|
+
encrypt_query_param: downloadParam,
|
|
591
|
+
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
|
592
|
+
encrypt_type: 1,
|
|
593
|
+
},
|
|
594
|
+
file_name: file.fileName,
|
|
595
|
+
len: String(file.bytes.byteLength),
|
|
596
|
+
},
|
|
597
|
+
}],
|
|
598
|
+
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
|
599
|
+
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
|
600
|
+
},
|
|
601
|
+
base_info: baseInfo(),
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
} catch (error) {
|
|
605
|
+
throw classifyWeixinFinalDeliveryError(error, signal);
|
|
606
|
+
}
|
|
607
|
+
const sendRejection = rejectedProviderResponse(response);
|
|
608
|
+
if (sendRejection) {
|
|
609
|
+
throw weixinArtifactError(new WeixinApiError(
|
|
610
|
+
'send-rejected',
|
|
611
|
+
'微信服务拒绝了文件消息。',
|
|
612
|
+
{ providerCode: sendRejection },
|
|
613
|
+
));
|
|
614
|
+
}
|
|
615
|
+
return { messageId: clientId };
|
|
616
|
+
},
|
|
617
|
+
|
|
352
618
|
async notifyStart({ baseUrl, token, signal }) {
|
|
353
619
|
const response = await requestJson(fetchImpl, {
|
|
354
620
|
method: 'POST',
|
|
@@ -32,6 +32,16 @@ import {
|
|
|
32
32
|
promptContentForMessage,
|
|
33
33
|
} from '../shared/image-prompt.mjs';
|
|
34
34
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
35
|
+
import {
|
|
36
|
+
materializeOutboundArtifact,
|
|
37
|
+
releaseOutboundArtifact,
|
|
38
|
+
} from '../shared/semantic/artifact.mjs';
|
|
39
|
+
import {
|
|
40
|
+
createArtifactFailureReceipt,
|
|
41
|
+
createDeliveryReceipt,
|
|
42
|
+
mergeDeliveryReceipts,
|
|
43
|
+
providerMessageIdsFor,
|
|
44
|
+
} from '../shared/semantic/delivery.mjs';
|
|
35
45
|
|
|
36
46
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
37
47
|
const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
|
|
@@ -89,6 +99,28 @@ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
|
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
function artifactFailureText(fileName, error) {
|
|
103
|
+
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
|
104
|
+
switch (error?.code) {
|
|
105
|
+
case 'artifact-delivery-uncertain':
|
|
106
|
+
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
|
107
|
+
case 'artifact-permission-required':
|
|
108
|
+
return `结果文件「${name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。`;
|
|
109
|
+
case 'artifact-too-large':
|
|
110
|
+
return `结果文件「${name}」超过当前微信会话可发送的文件大小,未发送。`;
|
|
111
|
+
case 'artifact-rate-limited':
|
|
112
|
+
return `结果文件「${name}」暂时被微信限流,未能发送,请稍后重试。`;
|
|
113
|
+
case 'artifact-provider-rejected':
|
|
114
|
+
return `结果文件「${name}」已生成,但微信拒绝了该文件消息。`;
|
|
115
|
+
case 'artifact-invalid':
|
|
116
|
+
case 'artifact-changed':
|
|
117
|
+
case 'artifact-unavailable':
|
|
118
|
+
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
|
119
|
+
default:
|
|
120
|
+
return `结果文件「${name}」已生成,但暂时未能通过微信发送,请稍后重试。`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
export function createWeixinBridgeStatus() {
|
|
93
125
|
return {
|
|
94
126
|
messagesReceived: 0,
|
|
@@ -394,8 +426,9 @@ export class WeixinHarnessBridge {
|
|
|
394
426
|
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
395
427
|
: undefined;
|
|
396
428
|
let answer;
|
|
429
|
+
let artifacts = [];
|
|
397
430
|
try {
|
|
398
|
-
({ answer } = await askInWorkspaceSession({
|
|
431
|
+
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
|
399
432
|
harness: this.#harness,
|
|
400
433
|
state: this.#state,
|
|
401
434
|
key,
|
|
@@ -421,12 +454,35 @@ export class WeixinHarnessBridge {
|
|
|
421
454
|
this.#approvals.closeRoute(key),
|
|
422
455
|
]);
|
|
423
456
|
}
|
|
424
|
-
|
|
457
|
+
const answerText = typeof answer === 'string' && answer.trim()
|
|
458
|
+
? answer
|
|
459
|
+
: artifacts.length > 0 ? '结果文件已生成。' : answer;
|
|
460
|
+
let textDeliveryError = null;
|
|
461
|
+
let textReceipt = null;
|
|
462
|
+
try {
|
|
463
|
+
textReceipt = createDeliveryReceipt({
|
|
464
|
+
deliveryId: messageId,
|
|
465
|
+
presentation: 'weixin-text',
|
|
466
|
+
providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
textDeliveryError = error;
|
|
470
|
+
}
|
|
471
|
+
const delivery = await this.#deliverArtifacts(
|
|
472
|
+
sender,
|
|
473
|
+
messageId,
|
|
474
|
+
artifacts,
|
|
475
|
+
contextToken,
|
|
476
|
+
runId,
|
|
477
|
+
textReceipt,
|
|
478
|
+
);
|
|
479
|
+
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
|
425
480
|
await this.#state.markSeen(messageId);
|
|
426
481
|
this.#status.messagesReplied += 1;
|
|
427
482
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
428
483
|
this.#status.lastError = null;
|
|
429
484
|
this.#status.lastMessageError = null;
|
|
485
|
+
return delivery.receipt;
|
|
430
486
|
} catch (error) {
|
|
431
487
|
if (error?.code === 'turn-stopped') {
|
|
432
488
|
await this.#state.markSeen(messageId);
|
|
@@ -762,15 +818,90 @@ export class WeixinHarnessBridge {
|
|
|
762
818
|
}
|
|
763
819
|
|
|
764
820
|
async #send(toUserId, text, contextToken, runId) {
|
|
821
|
+
const providerMessageIds = [];
|
|
765
822
|
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
|
766
|
-
await this.#api.sendText({
|
|
823
|
+
const result = await this.#api.sendText({
|
|
767
824
|
baseUrl: this.#baseUrl,
|
|
768
825
|
token: this.#token,
|
|
769
826
|
toUserId,
|
|
770
827
|
text: chunk,
|
|
771
828
|
contextToken,
|
|
772
829
|
runId,
|
|
830
|
+
signal: this.#signal,
|
|
773
831
|
});
|
|
832
|
+
providerMessageIds.push(...providerMessageIdsFor(result));
|
|
833
|
+
}
|
|
834
|
+
return providerMessageIds;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
|
838
|
+
const receipts = baseReceipt ? [baseReceipt] : [];
|
|
839
|
+
let userVisible = Boolean(baseReceipt);
|
|
840
|
+
for (const artifact of artifacts) {
|
|
841
|
+
this.#signal?.throwIfAborted();
|
|
842
|
+
try {
|
|
843
|
+
if (typeof this.#api.sendFile !== 'function') {
|
|
844
|
+
const unavailable = new Error('Weixin file delivery is unavailable');
|
|
845
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
846
|
+
throw unavailable;
|
|
847
|
+
}
|
|
848
|
+
const file = await materializeOutboundArtifact(artifact, {
|
|
849
|
+
signal: this.#signal,
|
|
850
|
+
});
|
|
851
|
+
const result = await this.#api.sendFile({
|
|
852
|
+
baseUrl: this.#baseUrl,
|
|
853
|
+
token: this.#token,
|
|
854
|
+
toUserId,
|
|
855
|
+
file,
|
|
856
|
+
contextToken,
|
|
857
|
+
runId,
|
|
858
|
+
signal: this.#signal,
|
|
859
|
+
});
|
|
860
|
+
receipts.push(createDeliveryReceipt({
|
|
861
|
+
deliveryId: file.deliveryKey,
|
|
862
|
+
presentation: 'weixin-file',
|
|
863
|
+
providerMessageIds: providerMessageIdsFor(result),
|
|
864
|
+
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
|
865
|
+
}));
|
|
866
|
+
userVisible = true;
|
|
867
|
+
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (this.#signal?.aborted) throw error;
|
|
870
|
+
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
|
871
|
+
this.#logger.warn?.(
|
|
872
|
+
`[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
|
|
873
|
+
);
|
|
874
|
+
let noticeSent = false;
|
|
875
|
+
const providerMessageIds = await this.#send(
|
|
876
|
+
toUserId,
|
|
877
|
+
artifactFailureText(artifact?.fileName, error),
|
|
878
|
+
contextToken,
|
|
879
|
+
runId,
|
|
880
|
+
).then((ids) => {
|
|
881
|
+
noticeSent = true;
|
|
882
|
+
return ids;
|
|
883
|
+
}).catch(() => []);
|
|
884
|
+
const failureReceipt = createArtifactFailureReceipt({
|
|
885
|
+
artifactId: artifact?.artifactId ?? 'unknown',
|
|
886
|
+
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
|
887
|
+
error,
|
|
888
|
+
providerMessageIds,
|
|
889
|
+
});
|
|
890
|
+
receipts.push(failureReceipt);
|
|
891
|
+
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
|
892
|
+
} finally {
|
|
893
|
+
releaseOutboundArtifact(artifact);
|
|
894
|
+
}
|
|
774
895
|
}
|
|
896
|
+
const receipt = receipts.length === 0
|
|
897
|
+
? null
|
|
898
|
+
: receipts.length === 1
|
|
899
|
+
? receipts[0]
|
|
900
|
+
: mergeDeliveryReceipts({
|
|
901
|
+
deliveryId: replyTo,
|
|
902
|
+
presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
|
903
|
+
receipts,
|
|
904
|
+
});
|
|
905
|
+
return { receipt, userVisible };
|
|
775
906
|
}
|
|
776
907
|
}
|
|
@@ -27,7 +27,11 @@ const ACTIVATION_ERROR_MESSAGES = Object.freeze({
|
|
|
27
27
|
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
|
28
28
|
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
|
29
29
|
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
|
30
|
-
'harness-
|
|
30
|
+
'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
|
|
31
|
+
'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
|
|
32
|
+
'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
|
|
33
|
+
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
|
|
34
|
+
'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
|
|
31
35
|
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
|
32
36
|
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
|
33
37
|
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
|
@@ -9,7 +9,11 @@ const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
|
|
9
9
|
const HARNESS_HEALTH_ERROR_CODES = new Set([
|
|
10
10
|
'harness-connect-failed',
|
|
11
11
|
'harness-timeout',
|
|
12
|
-
'harness-
|
|
12
|
+
'harness-auth-required',
|
|
13
|
+
'harness-proxy-auth-required',
|
|
14
|
+
'harness-loopback-forbidden',
|
|
15
|
+
'harness-host-untrusted',
|
|
16
|
+
'harness-request-forbidden',
|
|
13
17
|
'harness-api-not-found',
|
|
14
18
|
'harness-http-failed',
|
|
15
19
|
'harness-response-invalid',
|
|
@@ -5,6 +5,13 @@ import { dirname } from 'node:path';
|
|
|
5
5
|
const EMPTY_DOCUMENT = Object.freeze({ version: 2, bots: Object.freeze([]) });
|
|
6
6
|
const BOT_ID_PATTERN = /^whatsapp_[a-f0-9]{24}$/;
|
|
7
7
|
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
|
8
|
+
const WHATSAPP_PHONE_NUMBER = /^[1-9]\d{4,14}$/;
|
|
9
|
+
|
|
10
|
+
export const WHATSAPP_ACCESS_MODES = Object.freeze({
|
|
11
|
+
selfOnly: 'self-only',
|
|
12
|
+
privateAllowlist: 'private-allowlist',
|
|
13
|
+
open: 'open',
|
|
14
|
+
});
|
|
8
15
|
|
|
9
16
|
function cleanString(value) {
|
|
10
17
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -28,6 +35,35 @@ export function maskWhatsappAccount(accountJid) {
|
|
|
28
35
|
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
export function normalizeWhatsappAllowedNumbers(value) {
|
|
39
|
+
if (value === undefined) return Object.freeze([]);
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
throw new TypeError('allowedNumbers must be an array of WhatsApp phone numbers');
|
|
42
|
+
}
|
|
43
|
+
const normalized = value.map((entry) => {
|
|
44
|
+
const number = typeof entry === 'string' ? entry.trim().replace(/^\+/, '') : '';
|
|
45
|
+
if (!WHATSAPP_PHONE_NUMBER.test(number)) {
|
|
46
|
+
throw new TypeError('allowedNumbers contains an invalid WhatsApp phone number');
|
|
47
|
+
}
|
|
48
|
+
return number;
|
|
49
|
+
});
|
|
50
|
+
return Object.freeze([...new Set(normalized)]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeWhatsappAccessPolicy(value = {}) {
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
55
|
+
throw new TypeError('WhatsApp access policy must be an object');
|
|
56
|
+
}
|
|
57
|
+
const accessMode = value.accessMode ?? WHATSAPP_ACCESS_MODES.selfOnly;
|
|
58
|
+
if (!Object.values(WHATSAPP_ACCESS_MODES).includes(accessMode)) {
|
|
59
|
+
throw new TypeError('WhatsApp accessMode is invalid');
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
accessMode,
|
|
63
|
+
allowedNumbers: normalizeWhatsappAllowedNumbers(value.allowedNumbers),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
31
67
|
export class WhatsappConfigStore {
|
|
32
68
|
#path;
|
|
33
69
|
#value = EMPTY_DOCUMENT;
|
|
@@ -112,6 +148,12 @@ export class WhatsappConfigStore {
|
|
|
112
148
|
if (!accountJid || !botId || !authDirectory || !name
|
|
113
149
|
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|
|
114
150
|
|| deriveWhatsappBotId(accountJid) !== botId) return null;
|
|
151
|
+
let accessPolicy;
|
|
152
|
+
try {
|
|
153
|
+
accessPolicy = normalizeWhatsappAccessPolicy(value);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
115
157
|
return Object.freeze({
|
|
116
158
|
botId,
|
|
117
159
|
accountJid,
|
|
@@ -119,6 +161,7 @@ export class WhatsappConfigStore {
|
|
|
119
161
|
name,
|
|
120
162
|
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
121
163
|
connectedAt: cleanString(value.connectedAt),
|
|
164
|
+
...accessPolicy,
|
|
122
165
|
});
|
|
123
166
|
}
|
|
124
167
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
deriveWhatsappBotId,
|
|
6
|
+
maskWhatsappAccount,
|
|
7
|
+
normalizeWhatsappAccessPolicy,
|
|
8
|
+
} from './config-store.mjs';
|
|
5
9
|
|
|
6
10
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
7
11
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
@@ -218,6 +222,20 @@ export class WhatsappController {
|
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
async setAccessPolicy(botId, value) {
|
|
226
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
227
|
+
const accessPolicy = normalizeWhatsappAccessPolicy(value);
|
|
228
|
+
await this.#withBotTransition(botId, async () => {
|
|
229
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
230
|
+
const config = this.#configStore.get(botId);
|
|
231
|
+
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
232
|
+
const saved = await this.#configStore.save({ ...config, ...accessPolicy });
|
|
233
|
+
this.#runtimes.get(botId)?.setAccessPolicy?.(saved);
|
|
234
|
+
this.#touch();
|
|
235
|
+
});
|
|
236
|
+
return this.status();
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
async deleteBot(botId) {
|
|
222
240
|
const config = this.#configStore.get(botId);
|
|
223
241
|
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
@@ -266,6 +284,7 @@ export class WhatsappController {
|
|
|
266
284
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
267
285
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
268
286
|
},
|
|
287
|
+
accessPolicy: normalizeWhatsappAccessPolicy(config),
|
|
269
288
|
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
270
289
|
};
|
|
271
290
|
});
|
|
@@ -310,6 +329,8 @@ export class WhatsappController {
|
|
|
310
329
|
name: identity.name,
|
|
311
330
|
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
312
331
|
connectedAt: new Date().toISOString(),
|
|
332
|
+
accessMode: previous?.accessMode,
|
|
333
|
+
allowedNumbers: previous?.allowedNumbers,
|
|
313
334
|
};
|
|
314
335
|
try {
|
|
315
336
|
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|