@xmanrui/dsh-im 0.9.0 → 0.11.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 +7 -3
- package/README.md +7 -3
- package/lib/client.js +378 -27
- package/lib/index.js +134 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +54 -1
- package/src/channels/feishu/bridge.mjs +45 -18
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +227 -6
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +107 -20
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +2 -0
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/harness-client.mjs +14 -8
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +62 -16
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- package/src/channels/shared/workspace-session.mjs +2 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +95 -0
- package/src/channels/slack/slack-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +34 -3
- package/src/channels/telegram/telegram-api.mjs +37 -0
- package/src/channels/telegram/telegram-runtime.mjs +81 -9
- package/src/channels/wecom/wecom-bridge.mjs +160 -23
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +52 -19
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
2
4
|
|
|
3
5
|
export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
|
|
4
6
|
export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
|
|
5
7
|
export const DEFAULT_BOT_TYPE = '3';
|
|
8
|
+
export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
9
|
+
|
|
10
|
+
const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
|
|
6
11
|
|
|
7
12
|
const ILINK_APP_ID = 'bot';
|
|
8
13
|
const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
|
|
@@ -32,6 +37,93 @@ function nonEmptyString(value) {
|
|
|
32
37
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
function strictBase64(value) {
|
|
41
|
+
const text = nonEmptyString(value);
|
|
42
|
+
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
|
|
43
|
+
return Buffer.from(text, 'base64');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse the two AES key encodings used by Weixin iLink image messages. */
|
|
47
|
+
export function parseWeixinImageAesKey(imageItem) {
|
|
48
|
+
const directHex = nonEmptyString(imageItem?.aeskey);
|
|
49
|
+
if (directHex) {
|
|
50
|
+
if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
|
|
51
|
+
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
|
52
|
+
}
|
|
53
|
+
return Buffer.from(directHex, 'hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const encoded = strictBase64(imageItem?.media?.aes_key);
|
|
57
|
+
if (encoded?.length === 16) return encoded;
|
|
58
|
+
if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
|
|
59
|
+
return Buffer.from(encoded.toString('ascii'), 'hex');
|
|
60
|
+
}
|
|
61
|
+
throw new WeixinApiError('invalid-image-key', '微信图片的加密密钥无效。');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function decryptWeixinImage(ciphertext, key) {
|
|
65
|
+
const encrypted = Buffer.from(ciphertext);
|
|
66
|
+
const aesKey = Buffer.from(key);
|
|
67
|
+
if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
|
|
68
|
+
throw new WeixinApiError('invalid-image-ciphertext', '微信图片的加密数据无效。');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
|
|
72
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new WeixinApiError('image-decryption-failed', '微信图片解密失败。', { cause: error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function weixinImageDownloadUrl(media) {
|
|
79
|
+
const query = nonEmptyString(media?.encrypt_query_param);
|
|
80
|
+
if (query) {
|
|
81
|
+
return `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fullUrl = nonEmptyString(media?.full_url);
|
|
85
|
+
if (!fullUrl) throw new WeixinApiError('missing-image-url', '微信图片没有可用的下载地址。');
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(fullUrl);
|
|
89
|
+
} catch {
|
|
90
|
+
throw new WeixinApiError('invalid-image-url', '微信图片的下载地址无效。');
|
|
91
|
+
}
|
|
92
|
+
if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
|
|
93
|
+
|| (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
|
|
94
|
+
throw new WeixinApiError('untrusted-image-url', '微信图片的下载地址不受信任。');
|
|
95
|
+
}
|
|
96
|
+
url.username = '';
|
|
97
|
+
url.password = '';
|
|
98
|
+
url.hash = '';
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Convert iLink image items into lazily downloaded, decrypted image references. */
|
|
103
|
+
export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
|
|
104
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
105
|
+
const images = [];
|
|
106
|
+
for (const item of message?.item_list ?? []) {
|
|
107
|
+
const imageItem = item?.image_item;
|
|
108
|
+
if (!imageItem || typeof imageItem !== 'object') continue;
|
|
109
|
+
images.push({
|
|
110
|
+
name: images.length === 0 ? 'image' : `image-${images.length + 1}`,
|
|
111
|
+
load: async ({ signal, maxBytes }) => {
|
|
112
|
+
const key = parseWeixinImageAesKey(imageItem);
|
|
113
|
+
const url = weixinImageDownloadUrl(imageItem.media);
|
|
114
|
+
const ciphertext = await fetchImageBuffer(url, {
|
|
115
|
+
fetchImpl,
|
|
116
|
+
signal,
|
|
117
|
+
maxBytes: maxBytes + 16,
|
|
118
|
+
allowedHosts: [WEIXIN_CDN_HOST],
|
|
119
|
+
});
|
|
120
|
+
return decryptWeixinImage(ciphertext, key);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return images;
|
|
125
|
+
}
|
|
126
|
+
|
|
35
127
|
function isWeixinHost(hostname) {
|
|
36
128
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
37
129
|
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
|
@@ -92,7 +184,7 @@ function authenticatedHeaders(token) {
|
|
|
92
184
|
function baseInfo() {
|
|
93
185
|
return {
|
|
94
186
|
channel_version: WEIXIN_PROTOCOL_VERSION,
|
|
95
|
-
bot_agent: 'DeepSeekHarness/0.
|
|
187
|
+
bot_agent: 'DeepSeekHarness/0.11.0',
|
|
96
188
|
};
|
|
97
189
|
}
|
|
98
190
|
|
|
@@ -170,6 +262,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
170
262
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
171
263
|
|
|
172
264
|
return Object.freeze({
|
|
265
|
+
inboundImages(message) {
|
|
266
|
+
return extractWeixinImages(message, { fetchImpl });
|
|
267
|
+
},
|
|
268
|
+
|
|
173
269
|
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
|
174
270
|
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
|
175
271
|
const response = await requestJson(fetchImpl, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
extractWeixinImages,
|
|
2
3
|
extractWeixinText,
|
|
3
4
|
splitWeixinText,
|
|
4
5
|
weixinMessageId,
|
|
@@ -12,13 +13,19 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
|
12
13
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
13
14
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
14
15
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
16
|
+
import {
|
|
17
|
+
hasInboundImages,
|
|
18
|
+
imagePromptUserMessage,
|
|
19
|
+
promptContentForMessage,
|
|
20
|
+
} from '../shared/image-prompt.mjs';
|
|
21
|
+
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
15
22
|
|
|
16
23
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
17
24
|
|
|
18
25
|
const HELP_TEXT = [
|
|
19
26
|
'微信已连接 DeepSeek Harness。',
|
|
20
27
|
'',
|
|
21
|
-
'
|
|
28
|
+
'直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
|
|
22
29
|
'/new 开启一个全新会话',
|
|
23
30
|
'/compact 压缩当前会话的较早上下文',
|
|
24
31
|
'/workspace 工作区绝对路径 切换工作区',
|
|
@@ -37,9 +44,15 @@ function nonEmptyString(value) {
|
|
|
37
44
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
function hasWeixinImageItems(message) {
|
|
48
|
+
return Array.isArray(message?.item_list)
|
|
49
|
+
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
function canClaimInteractionReply(message, pending) {
|
|
41
53
|
return pending.questions[pending.index]
|
|
42
54
|
&& nonEmptyString(message?.from_user_id) === pending.actor
|
|
55
|
+
&& !hasWeixinImageItems(message)
|
|
43
56
|
&& nonEmptyString(extractWeixinText(message));
|
|
44
57
|
}
|
|
45
58
|
|
|
@@ -116,6 +129,9 @@ export class WeixinHarnessBridge {
|
|
|
116
129
|
if (!messageId || !sender || this.#state.hasSeen(messageId)
|
|
117
130
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
118
131
|
this.#acceptedMessageIds.add(messageId);
|
|
132
|
+
if (sender === this.#ownerUserId) {
|
|
133
|
+
rememberConnectionTestTarget(this.#state, { toUserId: sender });
|
|
134
|
+
}
|
|
119
135
|
const key = conversationKey(sender);
|
|
120
136
|
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
121
137
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
@@ -124,7 +140,7 @@ export class WeixinHarnessBridge {
|
|
|
124
140
|
key,
|
|
125
141
|
actor: sender,
|
|
126
142
|
messageId,
|
|
127
|
-
text: extractWeixinText(message),
|
|
143
|
+
text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
|
|
128
144
|
addressed: true,
|
|
129
145
|
hasPendingQuestion: Boolean(pending),
|
|
130
146
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -216,33 +232,40 @@ export class WeixinHarnessBridge {
|
|
|
216
232
|
|
|
217
233
|
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
|
218
234
|
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
|
219
|
-
const text = extractWeixinText(message);
|
|
235
|
+
const text = extractWeixinText(message) ?? '';
|
|
220
236
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
const images = typeof this.#api.inboundImages === 'function'
|
|
238
|
+
? this.#api.inboundImages(message)
|
|
239
|
+
: extractWeixinImages(message);
|
|
240
|
+
const promptMessage = { content: text, images };
|
|
241
|
+
const hasImages = hasInboundImages(promptMessage);
|
|
242
|
+
if (!text && !hasImages) {
|
|
243
|
+
await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
|
|
223
244
|
await this.#state.markSeen(messageId);
|
|
224
245
|
return;
|
|
225
246
|
}
|
|
226
247
|
|
|
227
248
|
const command = text.trim().toLowerCase();
|
|
228
|
-
if (command === '/help') {
|
|
249
|
+
if (!hasImages && command === '/help') {
|
|
229
250
|
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
|
230
251
|
await this.#state.markSeen(messageId);
|
|
231
252
|
return;
|
|
232
253
|
}
|
|
233
|
-
if (command === '/status') {
|
|
254
|
+
if (!hasImages && command === '/status') {
|
|
234
255
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
235
256
|
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
|
236
257
|
await this.#state.markSeen(messageId);
|
|
237
258
|
return;
|
|
238
259
|
}
|
|
239
|
-
if (command === '/new') {
|
|
260
|
+
if (!hasImages && command === '/new') {
|
|
240
261
|
await this.#state.clearSession(key);
|
|
241
262
|
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
|
242
263
|
await this.#state.markSeen(messageId);
|
|
243
264
|
return;
|
|
244
265
|
}
|
|
245
|
-
const workspaceCommand =
|
|
266
|
+
const workspaceCommand = hasImages
|
|
267
|
+
? null
|
|
268
|
+
: await runWorkspaceCommand(text, this.#harness, key);
|
|
246
269
|
if (workspaceCommand) {
|
|
247
270
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
248
271
|
await this.#send(sender, reply, contextToken, runId);
|
|
@@ -250,26 +273,31 @@ export class WeixinHarnessBridge {
|
|
|
250
273
|
await this.#state.markSeen(messageId);
|
|
251
274
|
return;
|
|
252
275
|
}
|
|
253
|
-
const compactCommand =
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
const compactCommand = hasImages
|
|
277
|
+
? null
|
|
278
|
+
: await runCompactCommand(
|
|
279
|
+
text,
|
|
280
|
+
this.#harness,
|
|
281
|
+
this.#state,
|
|
282
|
+
key,
|
|
283
|
+
{ signal: this.#signal },
|
|
284
|
+
);
|
|
260
285
|
if (compactCommand) {
|
|
261
286
|
await this.#send(sender, compactCommand.message, contextToken, runId);
|
|
262
287
|
await this.#state.markSeen(messageId);
|
|
263
288
|
return;
|
|
264
289
|
}
|
|
265
290
|
|
|
291
|
+
const content = hasImages
|
|
292
|
+
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
|
293
|
+
: undefined;
|
|
266
294
|
let answer;
|
|
267
295
|
try {
|
|
268
296
|
({ answer } = await askInWorkspaceSession({
|
|
269
297
|
harness: this.#harness,
|
|
270
298
|
state: this.#state,
|
|
271
299
|
key,
|
|
272
|
-
text,
|
|
300
|
+
...(hasImages ? { content } : { text }),
|
|
273
301
|
createOptions: { signal: this.#signal },
|
|
274
302
|
existsOptions: { signal: this.#signal },
|
|
275
303
|
askOptions: {
|
|
@@ -300,7 +328,12 @@ export class WeixinHarnessBridge {
|
|
|
300
328
|
this.#status.lastError = error?.message ?? String(error);
|
|
301
329
|
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
|
302
330
|
try {
|
|
303
|
-
await this.#send(
|
|
331
|
+
await this.#send(
|
|
332
|
+
sender,
|
|
333
|
+
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
|
|
334
|
+
contextToken,
|
|
335
|
+
runId,
|
|
336
|
+
);
|
|
304
337
|
await this.#state.markSeen(messageId);
|
|
305
338
|
} catch (sendError) {
|
|
306
339
|
this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
|
|
@@ -326,7 +359,7 @@ export class WeixinHarnessBridge {
|
|
|
326
359
|
const text = nonEmptyString(extractWeixinText(message));
|
|
327
360
|
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
328
361
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
329
|
-
if (!text) {
|
|
362
|
+
if (!text || hasWeixinImageItems(message)) {
|
|
330
363
|
await this.#send(
|
|
331
364
|
expected.actor,
|
|
332
365
|
'请用文字回答当前问题。',
|
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
WeixinApiError,
|
|
7
7
|
} from './weixin-api.mjs';
|
|
8
8
|
import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs';
|
|
9
|
+
import {
|
|
10
|
+
connectionTestMessage,
|
|
11
|
+
connectionTestTargetUnavailable,
|
|
12
|
+
} from '../shared/connection-test.mjs';
|
|
9
13
|
|
|
10
14
|
const ACTIVE_ATTEMPT_STATES = new Set([
|
|
11
15
|
'starting',
|
|
@@ -240,6 +244,20 @@ export class WeixinController {
|
|
|
240
244
|
return this.status();
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
async sendConnectionTest(botId) {
|
|
248
|
+
const config = this.#configStore.get(botId);
|
|
249
|
+
if (!config) throw new Error('Unknown Weixin account');
|
|
250
|
+
return this.#withBotTransition(botId, async () => {
|
|
251
|
+
const runtime = this.#runtimes.get(botId);
|
|
252
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
253
|
+
throw connectionTestTargetUnavailable('微信机器人');
|
|
254
|
+
}
|
|
255
|
+
return runtime.sendConnectionTest(connectionTestMessage(
|
|
256
|
+
`微信机器人(${maskWeixinAccountId(config.accountId)})`,
|
|
257
|
+
));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
async deleteBot(botId) {
|
|
244
262
|
const config = this.#configStore.get(botId);
|
|
245
263
|
if (!config) throw new Error('Unknown Weixin account');
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { WeixinApiError } from './weixin-api.mjs';
|
|
2
2
|
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
|
3
|
+
import {
|
|
4
|
+
connectionTestTarget,
|
|
5
|
+
connectionTestTargetUnavailable,
|
|
6
|
+
} from '../shared/connection-test.mjs';
|
|
3
7
|
|
|
4
8
|
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
|
5
9
|
|
|
@@ -250,4 +254,25 @@ export class WeixinRuntime {
|
|
|
250
254
|
this.#status.weixinConnectionState = 'idle';
|
|
251
255
|
return this.status;
|
|
252
256
|
}
|
|
257
|
+
|
|
258
|
+
async sendConnectionTest(text) {
|
|
259
|
+
const remembered = connectionTestTarget(this.#state);
|
|
260
|
+
const toUserId = typeof remembered?.toUserId === 'string' && remembered.toUserId.trim()
|
|
261
|
+
? remembered.toUserId.trim()
|
|
262
|
+
: typeof this.#config.ownerUserId === 'string' && this.#config.ownerUserId.trim()
|
|
263
|
+
? this.#config.ownerUserId.trim()
|
|
264
|
+
: null;
|
|
265
|
+
if (!toUserId) throw connectionTestTargetUnavailable('微信机器人');
|
|
266
|
+
if (!this.#status.ready || !this.#abortController) {
|
|
267
|
+
throw new Error('Weixin runtime is not connected');
|
|
268
|
+
}
|
|
269
|
+
await this.#api.sendText({
|
|
270
|
+
baseUrl: this.#config.baseUrl,
|
|
271
|
+
token: this.#token,
|
|
272
|
+
toUserId,
|
|
273
|
+
text,
|
|
274
|
+
signal: this.#abortController.signal,
|
|
275
|
+
});
|
|
276
|
+
return { sent: true };
|
|
277
|
+
}
|
|
253
278
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
3
4
|
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
|
|
4
5
|
|
|
5
6
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
@@ -198,6 +199,25 @@ export class WhatsappController {
|
|
|
198
199
|
return this.status();
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
async sendConnectionTest(botId) {
|
|
203
|
+
const config = this.#configStore.get(botId);
|
|
204
|
+
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
205
|
+
return this.#withBotTransition(botId, async () => {
|
|
206
|
+
const runtime = this.#runtimes.get(botId);
|
|
207
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
208
|
+
const error = new Error('WhatsApp机器人尚未连接');
|
|
209
|
+
error.code = 'test-target-unavailable';
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
return runtime.sendConnectionTest(
|
|
213
|
+
connectionTestMessage(
|
|
214
|
+
`${config.name}(${maskWhatsappAccount(config.accountJid)})`,
|
|
215
|
+
'WhatsApp机器人',
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
201
221
|
async deleteBot(botId) {
|
|
202
222
|
const config = this.#configStore.get(botId);
|
|
203
223
|
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
import {
|
|
2
2
|
areJidsSameUser,
|
|
3
|
+
downloadMediaMessage,
|
|
3
4
|
normalizeMessageContent,
|
|
4
5
|
} from '@whiskeysockets/baileys';
|
|
5
6
|
|
|
6
7
|
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
8
|
+
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
7
9
|
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
|
8
10
|
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
|
|
9
11
|
|
|
12
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
13
|
+
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
15
|
+
const MESSAGE_WRAPPER_KEYS = [
|
|
16
|
+
'ephemeralMessage',
|
|
17
|
+
'viewOnceMessage',
|
|
18
|
+
'documentWithCaptionMessage',
|
|
19
|
+
'viewOnceMessageV2',
|
|
20
|
+
'viewOnceMessageV2Extension',
|
|
21
|
+
'editedMessage',
|
|
22
|
+
'associatedChildMessage',
|
|
23
|
+
'groupStatusMessage',
|
|
24
|
+
'groupStatusMessageV2',
|
|
25
|
+
];
|
|
26
|
+
const VIEW_ONCE_WRAPPER_KEYS = new Set([
|
|
27
|
+
'viewOnceMessage',
|
|
28
|
+
'viewOnceMessageV2',
|
|
29
|
+
'viewOnceMessageV2Extension',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function hasViewOnceWrapper(content) {
|
|
33
|
+
let current = content;
|
|
34
|
+
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
|
|
35
|
+
const wrapperKey = MESSAGE_WRAPPER_KEYS.find((key) => current[key]?.message);
|
|
36
|
+
if (!wrapperKey) return false;
|
|
37
|
+
if (VIEW_ONCE_WRAPPER_KEYS.has(wrapperKey)) return true;
|
|
38
|
+
current = current[wrapperKey].message;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
10
43
|
function messageContext(content) {
|
|
11
44
|
return content?.extendedTextMessage?.contextInfo
|
|
12
45
|
?? content?.imageMessage?.contextInfo
|
|
@@ -24,7 +57,114 @@ function messageText(content) {
|
|
|
24
57
|
?? '';
|
|
25
58
|
}
|
|
26
59
|
|
|
27
|
-
|
|
60
|
+
function mediaSize(value) {
|
|
61
|
+
if (Number.isSafeInteger(value) && value >= 0) return value;
|
|
62
|
+
let converted;
|
|
63
|
+
try {
|
|
64
|
+
converted = Number(value?.toString?.());
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return Number.isSafeInteger(converted) && converted >= 0 ? converted : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function downloadWhatsappImage(message, download, {
|
|
72
|
+
signal,
|
|
73
|
+
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
74
|
+
} = {}) {
|
|
75
|
+
signal?.throwIfAborted();
|
|
76
|
+
const timeout = AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS);
|
|
77
|
+
const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
78
|
+
const pendingStream = Promise.resolve().then(() => (
|
|
79
|
+
download(message, 'stream', { options: { signal: downloadSignal } })
|
|
80
|
+
));
|
|
81
|
+
const stream = await new Promise((resolve, reject) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
const finish = (callback, value) => {
|
|
84
|
+
if (settled) return false;
|
|
85
|
+
settled = true;
|
|
86
|
+
downloadSignal.removeEventListener('abort', onAbort);
|
|
87
|
+
callback(value);
|
|
88
|
+
return true;
|
|
89
|
+
};
|
|
90
|
+
const onAbort = () => finish(reject, downloadSignal.reason);
|
|
91
|
+
downloadSignal.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
pendingStream.then((value) => {
|
|
93
|
+
if (!finish(resolve, value)) value?.destroy?.();
|
|
94
|
+
}, (error) => finish(reject, error));
|
|
95
|
+
if (downloadSignal.aborted) onAbort();
|
|
96
|
+
}).catch((error) => {
|
|
97
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
98
|
+
if (timeout.aborted) {
|
|
99
|
+
throw new ImagePromptError(
|
|
100
|
+
'image-download-failed',
|
|
101
|
+
`WhatsApp image download timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
|
|
102
|
+
'图片下载失败,请重新发送后再试。',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
const abortStream = () => stream?.destroy?.(downloadSignal.reason);
|
|
110
|
+
downloadSignal.addEventListener('abort', abortStream, { once: true });
|
|
111
|
+
try {
|
|
112
|
+
for await (const chunk of stream) {
|
|
113
|
+
downloadSignal.throwIfAborted();
|
|
114
|
+
const data = Buffer.from(chunk);
|
|
115
|
+
size += data.length;
|
|
116
|
+
if (size > maxBytes) {
|
|
117
|
+
stream.destroy?.();
|
|
118
|
+
throw new ImagePromptError(
|
|
119
|
+
'image-too-large',
|
|
120
|
+
`WhatsApp image exceeded ${maxBytes} bytes`,
|
|
121
|
+
'图片超过 5 MB,请压缩后重试。',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
chunks.push(data);
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
128
|
+
if (timeout.aborted) {
|
|
129
|
+
throw new ImagePromptError(
|
|
130
|
+
'image-download-failed',
|
|
131
|
+
`WhatsApp image stream timed out after ${IMAGE_DOWNLOAD_TIMEOUT_MS} ms`,
|
|
132
|
+
'图片下载失败,请重新发送后再试。',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
} finally {
|
|
137
|
+
downloadSignal.removeEventListener('abort', abortStream);
|
|
138
|
+
}
|
|
139
|
+
return Buffer.concat(chunks, size);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function whatsappImageSource(message, content, download, { viewOnce = false } = {}) {
|
|
143
|
+
let media;
|
|
144
|
+
let name;
|
|
145
|
+
if (!viewOnce && content?.imageMessage && content.imageMessage.viewOnce !== true) {
|
|
146
|
+
media = content.imageMessage;
|
|
147
|
+
} else if (content?.documentMessage) {
|
|
148
|
+
const type = typeof content.documentMessage.mimetype === 'string'
|
|
149
|
+
? content.documentMessage.mimetype.toLowerCase() : '';
|
|
150
|
+
if (!IMAGE_MEDIA_TYPES.has(type)) return null;
|
|
151
|
+
media = content.documentMessage;
|
|
152
|
+
name = typeof media.fileName === 'string' ? media.fileName : undefined;
|
|
153
|
+
}
|
|
154
|
+
if (!media) return null;
|
|
155
|
+
const mediaType = typeof media.mimetype === 'string' ? media.mimetype.toLowerCase() : '';
|
|
156
|
+
if (!IMAGE_MEDIA_TYPES.has(mediaType)) return null;
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
mediaType,
|
|
160
|
+
size: mediaSize(media.fileLength),
|
|
161
|
+
load: (options) => downloadWhatsappImage(message, download, options),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function normalizeWhatsappMessage(message, accountJid, {
|
|
166
|
+
download = downloadMediaMessage,
|
|
167
|
+
} = {}) {
|
|
28
168
|
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
|
|
29
169
|
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
|
|
30
170
|
? message.key.remoteJidAlt : '';
|
|
@@ -38,12 +178,14 @@ export function normalizeWhatsappMessage(message, accountJid) {
|
|
|
38
178
|
if (fromMe && !selfChat) return null;
|
|
39
179
|
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
|
|
40
180
|
if (typeof senderJid !== 'string' || !senderJid) return null;
|
|
181
|
+
const viewOnce = hasViewOnceWrapper(message.message);
|
|
41
182
|
const content = normalizeMessageContent(message.message);
|
|
42
183
|
const context = messageContext(content);
|
|
43
184
|
const mentioned = Array.isArray(context?.mentionedJid)
|
|
44
185
|
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
|
|
45
186
|
const replyToSelf = typeof context?.participant === 'string'
|
|
46
187
|
&& areJidsSameUser(context.participant, accountJid);
|
|
188
|
+
const image = whatsappImageSource(message, content, download, { viewOnce });
|
|
47
189
|
return {
|
|
48
190
|
messageId: `${remoteJid}:${messageId}`,
|
|
49
191
|
providerMessageId: messageId,
|
|
@@ -52,6 +194,7 @@ export function normalizeWhatsappMessage(message, accountJid) {
|
|
|
52
194
|
kind: group ? 'group' : 'direct',
|
|
53
195
|
conversationId: remoteJid,
|
|
54
196
|
content: messageText(content),
|
|
197
|
+
images: image ? [image] : [],
|
|
55
198
|
addressed: !group || mentioned || replyToSelf,
|
|
56
199
|
selfChat,
|
|
57
200
|
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
|
@@ -278,6 +421,22 @@ export class WhatsappRuntime {
|
|
|
278
421
|
return this.stop();
|
|
279
422
|
}
|
|
280
423
|
|
|
424
|
+
async sendConnectionTest(text) {
|
|
425
|
+
if (!this.#status.ready || !this.#client) {
|
|
426
|
+
const error = new Error('WhatsApp机器人尚未连接');
|
|
427
|
+
error.code = 'test-target-unavailable';
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
431
|
+
throw new TypeError('WhatsApp connection test text is required');
|
|
432
|
+
}
|
|
433
|
+
await this.#client.sendText({
|
|
434
|
+
jid: this.#config.accountJid,
|
|
435
|
+
selfChat: true,
|
|
436
|
+
}, text);
|
|
437
|
+
return { sent: true };
|
|
438
|
+
}
|
|
439
|
+
|
|
281
440
|
async stop() {
|
|
282
441
|
const session = this.#session;
|
|
283
442
|
const client = this.#client;
|