@xmanrui/dsh-im 0.6.0 → 0.7.1
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.md +22 -4
- package/lib/client.js +649 -357
- package/lib/index.js +120 -111
- package/package.json +1 -1
- package/plugin-src/client/build.mjs +1 -1
- package/plugin-src/client/i18n.js +14 -0
- package/plugin-src/client/index.js +11 -3
- package/plugin-src/client/styles.js +49 -8
- package/plugin-src/client/workspace-directory-picker.js +230 -0
- package/plugin-src/client/workspace-editor.js +38 -65
- package/scripts/verify-package.mjs +5 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +363 -9
- package/src/channels/dingtalk/harness-client.mjs +16 -310
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +11 -1
- package/src/channels/discord/harness-client.mjs +10 -2
- package/src/channels/feishu/bridge.mjs +525 -51
- package/src/channels/feishu/feishu-runtime.mjs +41 -1
- package/src/channels/feishu/harness-client.mjs +16 -279
- package/src/channels/qq/harness-client.mjs +10 -2
- package/src/channels/qq/qq-bridge.mjs +383 -29
- package/src/channels/qq/qq-runtime.mjs +14 -3
- package/src/channels/shared/bot-workspace-store.mjs +185 -4
- package/src/channels/shared/harness-client.mjs +825 -0
- package/src/channels/shared/harness-question.mjs +85 -0
- package/src/channels/shared/harness-session-binding.mjs +110 -0
- package/src/channels/shared/text-harness-bridge.mjs +439 -25
- package/src/channels/shared/workspace-command.mjs +212 -16
- package/src/channels/shared/workspace-session.mjs +22 -9
- package/src/channels/slack/harness-client.mjs +10 -2
- package/src/channels/slack/slack-runtime.mjs +11 -1
- package/src/channels/telegram/harness-client.mjs +10 -2
- package/src/channels/telegram/telegram-runtime.mjs +15 -4
- package/src/channels/wecom/harness-client.mjs +10 -2
- package/src/channels/wecom/wecom-bridge.mjs +389 -15
- package/src/channels/wecom/wecom-runtime.mjs +6 -0
- package/src/channels/weixin/harness-client.mjs +16 -270
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +406 -24
- package/src/channels/weixin/weixin-runtime.mjs +56 -7
- package/src/channels/whatsapp/harness-client.mjs +10 -2
- package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
|
@@ -3,9 +3,16 @@ import {
|
|
|
3
3
|
splitWeixinText,
|
|
4
4
|
weixinMessageId,
|
|
5
5
|
} from './weixin-api.mjs';
|
|
6
|
+
import {
|
|
7
|
+
harnessAnswerForQuestion,
|
|
8
|
+
harnessQuestionText,
|
|
9
|
+
validHarnessQuestion,
|
|
10
|
+
} from '../shared/harness-question.mjs';
|
|
6
11
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
7
12
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
8
13
|
|
|
14
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
15
|
+
|
|
9
16
|
const HELP_TEXT = [
|
|
10
17
|
'微信已连接 DeepSeek Harness。',
|
|
11
18
|
'',
|
|
@@ -13,6 +20,8 @@ const HELP_TEXT = [
|
|
|
13
20
|
'/new 开启一个全新会话',
|
|
14
21
|
'/workspace 工作区绝对路径 切换工作区',
|
|
15
22
|
'/workspacelist 列出工作区绝对路径',
|
|
23
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
24
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
16
25
|
'/status 检查连接状态',
|
|
17
26
|
'/help 显示本帮助',
|
|
18
27
|
].join('\n');
|
|
@@ -21,6 +30,16 @@ function conversationKey(userId) {
|
|
|
21
30
|
return `p2p:${userId}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
function nonEmptyString(value) {
|
|
34
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canClaimInteractionReply(message, pending) {
|
|
38
|
+
return pending.questions[pending.index]
|
|
39
|
+
&& nonEmptyString(message?.from_user_id) === pending.actor
|
|
40
|
+
&& nonEmptyString(extractWeixinText(message));
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export function createWeixinBridgeStatus() {
|
|
25
44
|
return {
|
|
26
45
|
messagesReceived: 0,
|
|
@@ -44,7 +63,11 @@ export class WeixinHarnessBridge {
|
|
|
44
63
|
#logger;
|
|
45
64
|
#replyTimeoutMs;
|
|
46
65
|
#maxMessageChars;
|
|
66
|
+
#signal;
|
|
47
67
|
#queues = new Map();
|
|
68
|
+
#pendingInteractions = new Map();
|
|
69
|
+
#interactionKeys = new Map();
|
|
70
|
+
#acceptedMessageIds = new Set();
|
|
48
71
|
|
|
49
72
|
constructor({
|
|
50
73
|
api,
|
|
@@ -57,6 +80,7 @@ export class WeixinHarnessBridge {
|
|
|
57
80
|
logger = console,
|
|
58
81
|
replyTimeoutMs = 600_000,
|
|
59
82
|
maxMessageChars = 4_000,
|
|
83
|
+
signal,
|
|
60
84
|
}) {
|
|
61
85
|
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
|
|
62
86
|
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
|
|
@@ -71,6 +95,7 @@ export class WeixinHarnessBridge {
|
|
|
71
95
|
this.#logger = logger;
|
|
72
96
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
73
97
|
this.#maxMessageChars = maxMessageChars;
|
|
98
|
+
this.#signal = signal;
|
|
74
99
|
}
|
|
75
100
|
|
|
76
101
|
get status() {
|
|
@@ -78,31 +103,73 @@ export class WeixinHarnessBridge {
|
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
accept(message) {
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
107
|
+
if (message?.message_type === 2) return Promise.resolve();
|
|
108
|
+
const messageId = weixinMessageId(message);
|
|
109
|
+
const sender = nonEmptyString(message?.from_user_id);
|
|
110
|
+
if (!messageId || !sender || this.#state.hasSeen(messageId)
|
|
111
|
+
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
112
|
+
this.#acceptedMessageIds.add(messageId);
|
|
113
|
+
const key = conversationKey(sender);
|
|
114
|
+
const pending = this.#pendingInteractions.get(key);
|
|
115
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
116
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
117
|
+
}
|
|
118
|
+
if (pending) {
|
|
119
|
+
if (canClaimInteractionReply(message, pending)) {
|
|
120
|
+
pending.claimedReplyMessageId = messageId;
|
|
121
|
+
}
|
|
122
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
123
|
+
const current = previous
|
|
124
|
+
.catch(() => undefined)
|
|
125
|
+
.then(() => this.#processInteractionReply(message, messageId, key, pending))
|
|
126
|
+
.catch((error) => this.#handleInteractionFailure(message, messageId, error))
|
|
127
|
+
.finally(() => {
|
|
128
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
129
|
+
if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
|
|
130
|
+
if (pending.queue === current) pending.queue = null;
|
|
131
|
+
});
|
|
132
|
+
pending.queue = current;
|
|
133
|
+
return current;
|
|
134
|
+
}
|
|
135
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#enqueueMessage(message, messageId, key, {
|
|
139
|
+
releaseMessageId = true,
|
|
140
|
+
alreadyRecorded = false,
|
|
141
|
+
} = {}) {
|
|
142
|
+
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
83
143
|
const current = previous
|
|
84
144
|
.catch(() => undefined)
|
|
85
|
-
.then(() => this.#process(message))
|
|
145
|
+
.then(() => this.#process(message, key, { alreadyRecorded }))
|
|
86
146
|
.finally(() => {
|
|
87
|
-
if (
|
|
147
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
148
|
+
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
88
149
|
});
|
|
89
|
-
this.#queues.set(
|
|
150
|
+
this.#queues.set(key, current);
|
|
90
151
|
return current;
|
|
91
152
|
}
|
|
92
153
|
|
|
93
154
|
async waitForIdle() {
|
|
94
|
-
await Promise.allSettled([
|
|
155
|
+
await Promise.allSettled([
|
|
156
|
+
...this.#queues.values(),
|
|
157
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
158
|
+
pending.queue ? [pending.queue] : []
|
|
159
|
+
)),
|
|
160
|
+
]);
|
|
95
161
|
}
|
|
96
162
|
|
|
97
|
-
async #process(message) {
|
|
98
|
-
|
|
163
|
+
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
164
|
+
this.#signal?.throwIfAborted();
|
|
99
165
|
const messageId = weixinMessageId(message);
|
|
100
|
-
const sender =
|
|
166
|
+
const sender = nonEmptyString(message?.from_user_id);
|
|
101
167
|
if (!messageId || !sender) return;
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
168
|
+
if (!alreadyRecorded) {
|
|
169
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
170
|
+
this.#status.messagesReceived += 1;
|
|
171
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
172
|
+
}
|
|
106
173
|
if (sender !== this.#ownerUserId) {
|
|
107
174
|
this.#status.messagesRejected += 1;
|
|
108
175
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
@@ -126,18 +193,18 @@ export class WeixinHarnessBridge {
|
|
|
126
193
|
return;
|
|
127
194
|
}
|
|
128
195
|
if (command === '/status') {
|
|
129
|
-
await this.#harness.ensureRunning();
|
|
196
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
130
197
|
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
|
131
198
|
await this.#state.markSeen(messageId);
|
|
132
199
|
return;
|
|
133
200
|
}
|
|
134
201
|
if (command === '/new') {
|
|
135
|
-
await this.#state.clearSession(
|
|
202
|
+
await this.#state.clearSession(key);
|
|
136
203
|
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
|
137
204
|
await this.#state.markSeen(messageId);
|
|
138
205
|
return;
|
|
139
206
|
}
|
|
140
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
207
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
141
208
|
if (workspaceCommand) {
|
|
142
209
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
143
210
|
await this.#send(sender, reply, contextToken, runId);
|
|
@@ -146,20 +213,37 @@ export class WeixinHarnessBridge {
|
|
|
146
213
|
return;
|
|
147
214
|
}
|
|
148
215
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
216
|
+
let answer;
|
|
217
|
+
try {
|
|
218
|
+
({ answer } = await askInWorkspaceSession({
|
|
219
|
+
harness: this.#harness,
|
|
220
|
+
state: this.#state,
|
|
221
|
+
key,
|
|
222
|
+
text,
|
|
223
|
+
createOptions: { signal: this.#signal },
|
|
224
|
+
existsOptions: { signal: this.#signal },
|
|
225
|
+
askOptions: {
|
|
226
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
227
|
+
signal: this.#signal,
|
|
228
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
229
|
+
key,
|
|
230
|
+
actor: sender,
|
|
231
|
+
contextToken,
|
|
232
|
+
runId,
|
|
233
|
+
}),
|
|
234
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
235
|
+
},
|
|
236
|
+
}));
|
|
237
|
+
} finally {
|
|
238
|
+
await this.#cancelPendingInteraction(key);
|
|
239
|
+
}
|
|
157
240
|
await this.#send(sender, answer, contextToken, runId);
|
|
158
241
|
await this.#state.markSeen(messageId);
|
|
159
242
|
this.#status.messagesReplied += 1;
|
|
160
243
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
161
244
|
this.#status.lastError = null;
|
|
162
245
|
} catch (error) {
|
|
246
|
+
if (this.#signal?.aborted) return;
|
|
163
247
|
this.#status.lastError = error?.message ?? String(error);
|
|
164
248
|
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
|
165
249
|
try {
|
|
@@ -171,6 +255,304 @@ export class WeixinHarnessBridge {
|
|
|
171
255
|
}
|
|
172
256
|
}
|
|
173
257
|
|
|
258
|
+
async #processInteractionReply(message, messageId, key, expected) {
|
|
259
|
+
this.#signal?.throwIfAborted();
|
|
260
|
+
const current = this.#pendingInteractions.get(key);
|
|
261
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
262
|
+
if (!current || current !== expected || current.submitting) {
|
|
263
|
+
if (claimed && (!current || current !== expected)) {
|
|
264
|
+
return this.#discardResolvedInteractionReply(message, messageId);
|
|
265
|
+
}
|
|
266
|
+
return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
|
|
267
|
+
}
|
|
268
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
269
|
+
await this.#state.markSeen(messageId);
|
|
270
|
+
this.#status.messagesReceived += 1;
|
|
271
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
272
|
+
|
|
273
|
+
const text = nonEmptyString(extractWeixinText(message));
|
|
274
|
+
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
275
|
+
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
276
|
+
if (!text) {
|
|
277
|
+
await this.#send(
|
|
278
|
+
expected.actor,
|
|
279
|
+
'请用文字回答当前问题。',
|
|
280
|
+
contextToken,
|
|
281
|
+
runId,
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const pending = this.#pendingInteractions.get(key);
|
|
287
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
288
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
289
|
+
await this.#send(
|
|
290
|
+
expected.actor,
|
|
291
|
+
INTERACTION_RESOLVED_TEXT,
|
|
292
|
+
contextToken,
|
|
293
|
+
runId,
|
|
294
|
+
);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
298
|
+
releaseMessageId: false,
|
|
299
|
+
alreadyRecorded: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
pending.contextToken = contextToken;
|
|
303
|
+
pending.runId = runId;
|
|
304
|
+
if (pending.needsPresentation) {
|
|
305
|
+
try {
|
|
306
|
+
await this.#presentInteraction(pending);
|
|
307
|
+
} catch {
|
|
308
|
+
this.#status.lastError = '微信交互问题发送失败。';
|
|
309
|
+
this.#logger.error?.('[dsh-weixin] failed to retry an interaction question');
|
|
310
|
+
pending.interaction.reconnect?.();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const presentedPending = this.#pendingInteractions.get(key);
|
|
314
|
+
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
|
|
315
|
+
if (claimed && (!presentedPending || presentedPending !== expected)) {
|
|
316
|
+
await this.#send(
|
|
317
|
+
expected.actor,
|
|
318
|
+
INTERACTION_RESOLVED_TEXT,
|
|
319
|
+
contextToken,
|
|
320
|
+
runId,
|
|
321
|
+
).catch(() => undefined);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
325
|
+
releaseMessageId: false,
|
|
326
|
+
alreadyRecorded: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const question = pending.questions[pending.index];
|
|
332
|
+
if (!question) return;
|
|
333
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
334
|
+
pending.index += 1;
|
|
335
|
+
if (pending.index < pending.questions.length) {
|
|
336
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
337
|
+
pending.claimedReplyMessageId = null;
|
|
338
|
+
}
|
|
339
|
+
pending.needsPresentation = true;
|
|
340
|
+
try {
|
|
341
|
+
await this.#presentInteraction(pending);
|
|
342
|
+
} catch {
|
|
343
|
+
this.#status.lastError = '微信交互问题发送失败。';
|
|
344
|
+
this.#logger.error?.('[dsh-weixin] failed to send the next interaction question');
|
|
345
|
+
pending.interaction.reconnect?.();
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
pending.submitting = true;
|
|
351
|
+
try {
|
|
352
|
+
await pending.interaction.respond({
|
|
353
|
+
ok: true,
|
|
354
|
+
value: {
|
|
355
|
+
sessionId: pending.sessionId,
|
|
356
|
+
answer: { answers: pending.answers },
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
360
|
+
this.#status.lastError = null;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (this.#signal?.aborted) return;
|
|
363
|
+
if (error?.code === 'interaction-not-pending') {
|
|
364
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
365
|
+
await this.#send(
|
|
366
|
+
pending.actor,
|
|
367
|
+
INTERACTION_RESOLVED_TEXT,
|
|
368
|
+
pending.contextToken,
|
|
369
|
+
pending.runId,
|
|
370
|
+
).catch(() => undefined);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
374
|
+
pending.submitting = false;
|
|
375
|
+
pending.answers.pop();
|
|
376
|
+
pending.index -= 1;
|
|
377
|
+
this.#status.lastError = '回答提交失败。';
|
|
378
|
+
this.#logger.error?.('[dsh-weixin] failed to answer a Harness interaction');
|
|
379
|
+
await this.#send(
|
|
380
|
+
pending.actor,
|
|
381
|
+
'回答提交失败,请重新发送当前问题的答案。',
|
|
382
|
+
pending.contextToken,
|
|
383
|
+
pending.runId,
|
|
384
|
+
).catch(() => undefined);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async #handleInteraction(interaction, {
|
|
389
|
+
key,
|
|
390
|
+
actor,
|
|
391
|
+
contextToken,
|
|
392
|
+
runId,
|
|
393
|
+
}) {
|
|
394
|
+
// Approval remains fail-closed until #5 adds an authenticated policy.
|
|
395
|
+
if (interaction?.kind !== 'question') return;
|
|
396
|
+
const questions = interaction?.payload?.questions;
|
|
397
|
+
const interactionId = typeof interaction?.interactionId === 'string'
|
|
398
|
+
? interaction.interactionId
|
|
399
|
+
: interaction?.rpcId;
|
|
400
|
+
if (typeof interaction?.rpcId !== 'string'
|
|
401
|
+
|| typeof interactionId !== 'string'
|
|
402
|
+
|| typeof interaction.sessionId !== 'string'
|
|
403
|
+
|| !Array.isArray(questions)
|
|
404
|
+
|| questions.length === 0
|
|
405
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
406
|
+
this.#logger.warn?.('[dsh-weixin] ignored an invalid Harness question interaction');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (interaction.recovered === true) {
|
|
411
|
+
await interaction.respond({
|
|
412
|
+
ok: false,
|
|
413
|
+
error: {
|
|
414
|
+
code: 'cancelled',
|
|
415
|
+
message: 'Weixin safely cancelled an interaction left by an earlier client.',
|
|
416
|
+
details: {},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
await this.#send(
|
|
420
|
+
actor,
|
|
421
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
422
|
+
contextToken,
|
|
423
|
+
runId,
|
|
424
|
+
).catch(() => undefined);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const existing = this.#pendingInteractions.get(key);
|
|
429
|
+
if (existing?.interactionId === interactionId) {
|
|
430
|
+
existing.interaction = interaction;
|
|
431
|
+
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (this.#interactionKeys.has(interactionId)) return;
|
|
435
|
+
if (existing) {
|
|
436
|
+
await interaction.respond({
|
|
437
|
+
ok: false,
|
|
438
|
+
error: {
|
|
439
|
+
code: 'cancelled',
|
|
440
|
+
message: 'Weixin is already handling another user interaction.',
|
|
441
|
+
details: {},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const pending = {
|
|
448
|
+
kind: 'question',
|
|
449
|
+
interactionId,
|
|
450
|
+
sessionId: interaction.sessionId,
|
|
451
|
+
interaction,
|
|
452
|
+
actor,
|
|
453
|
+
questions,
|
|
454
|
+
answers: [],
|
|
455
|
+
index: 0,
|
|
456
|
+
contextToken,
|
|
457
|
+
runId,
|
|
458
|
+
queue: null,
|
|
459
|
+
claimedReplyMessageId: null,
|
|
460
|
+
presentationPromise: null,
|
|
461
|
+
submitting: false,
|
|
462
|
+
needsPresentation: true,
|
|
463
|
+
};
|
|
464
|
+
this.#pendingInteractions.set(key, pending);
|
|
465
|
+
this.#interactionKeys.set(interactionId, key);
|
|
466
|
+
await this.#presentInteraction(pending);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#handleInteractionResolved(resolution) {
|
|
470
|
+
const interactionId = resolution?.interactionId;
|
|
471
|
+
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
|
472
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
473
|
+
if (!key) return;
|
|
474
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#presentInteraction(pending) {
|
|
478
|
+
if (!pending.needsPresentation) return Promise.resolve();
|
|
479
|
+
if (pending.presentationPromise) return pending.presentationPromise;
|
|
480
|
+
const question = pending.questions[pending.index];
|
|
481
|
+
if (!question) return Promise.resolve();
|
|
482
|
+
const presentation = this.#send(
|
|
483
|
+
pending.actor,
|
|
484
|
+
harnessQuestionText(question, pending.index, pending.questions.length),
|
|
485
|
+
pending.contextToken,
|
|
486
|
+
pending.runId,
|
|
487
|
+
).then(() => {
|
|
488
|
+
pending.needsPresentation = false;
|
|
489
|
+
}).finally(() => {
|
|
490
|
+
if (pending.presentationPromise === presentation) pending.presentationPromise = null;
|
|
491
|
+
});
|
|
492
|
+
pending.presentationPromise = presentation;
|
|
493
|
+
return presentation;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async #discardResolvedInteractionReply(message, messageId) {
|
|
497
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
498
|
+
await this.#state.markSeen(messageId);
|
|
499
|
+
this.#status.messagesReceived += 1;
|
|
500
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
501
|
+
await this.#send(
|
|
502
|
+
nonEmptyString(message?.from_user_id),
|
|
503
|
+
INTERACTION_RESOLVED_TEXT,
|
|
504
|
+
nonEmptyString(message?.context_token) ?? undefined,
|
|
505
|
+
nonEmptyString(message?.run_id) ?? undefined,
|
|
506
|
+
).catch(() => undefined);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#takePendingInteraction(key, interactionId) {
|
|
510
|
+
const pending = this.#pendingInteractions.get(key);
|
|
511
|
+
if (!pending
|
|
512
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
513
|
+
this.#pendingInteractions.delete(key);
|
|
514
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
515
|
+
return pending;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#clearPendingInteraction(key, interactionId) {
|
|
519
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async #cancelPendingInteraction(key) {
|
|
523
|
+
const pending = this.#takePendingInteraction(key);
|
|
524
|
+
if (!pending || pending.kind !== 'question') return;
|
|
525
|
+
try {
|
|
526
|
+
await pending.interaction.respond({
|
|
527
|
+
ok: false,
|
|
528
|
+
error: {
|
|
529
|
+
code: 'cancelled',
|
|
530
|
+
message: 'The Weixin interaction ended before the user answered.',
|
|
531
|
+
details: {},
|
|
532
|
+
},
|
|
533
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
536
|
+
this.#logger.warn?.('[dsh-weixin] failed to cancel a pending Harness interaction');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async #handleInteractionFailure(message, messageId, error) {
|
|
542
|
+
if (this.#signal?.aborted) return;
|
|
543
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
544
|
+
this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
|
|
545
|
+
if (!this.#state.hasSeen(messageId)) {
|
|
546
|
+
await this.#state.markSeen(messageId).catch(() => undefined);
|
|
547
|
+
}
|
|
548
|
+
await this.#send(
|
|
549
|
+
nonEmptyString(message?.from_user_id),
|
|
550
|
+
'消息处理失败,请稍后重试。',
|
|
551
|
+
nonEmptyString(message?.context_token) ?? undefined,
|
|
552
|
+
nonEmptyString(message?.run_id) ?? undefined,
|
|
553
|
+
).catch(() => undefined);
|
|
554
|
+
}
|
|
555
|
+
|
|
174
556
|
async #send(toUserId, text, contextToken, runId) {
|
|
175
557
|
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
|
176
558
|
await this.#api.sendText({
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { WeixinApiError } from './weixin-api.mjs';
|
|
2
2
|
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
|
|
3
3
|
|
|
4
|
+
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
|
5
|
+
|
|
6
|
+
function startRetryDelays(value) {
|
|
7
|
+
if (value === undefined) return [...DEFAULT_START_RETRY_DELAYS_MS];
|
|
8
|
+
if (!Array.isArray(value)) throw new TypeError('startRetryDelaysMs must be an array');
|
|
9
|
+
return value.map((wait) => {
|
|
10
|
+
if (!Number.isFinite(wait) || wait < 0) {
|
|
11
|
+
throw new TypeError('startRetryDelaysMs must contain non-negative delays');
|
|
12
|
+
}
|
|
13
|
+
return wait;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function retryableStartError(error) {
|
|
18
|
+
if (!(error instanceof WeixinApiError)) return false;
|
|
19
|
+
if (error.code === 'network-error' || error.code === 'timeout') return true;
|
|
20
|
+
return error.code === 'http-error'
|
|
21
|
+
&& (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500);
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
function delay(ms, signal) {
|
|
5
25
|
return new Promise((resolve, reject) => {
|
|
6
26
|
if (signal?.aborted) {
|
|
@@ -42,6 +62,7 @@ export class WeixinRuntime {
|
|
|
42
62
|
#logger;
|
|
43
63
|
#replyTimeoutMs;
|
|
44
64
|
#maxMessageChars;
|
|
65
|
+
#startRetryDelaysMs;
|
|
45
66
|
#status = createWeixinRuntimeStatus();
|
|
46
67
|
#bridge = null;
|
|
47
68
|
#abortController = null;
|
|
@@ -57,6 +78,7 @@ export class WeixinRuntime {
|
|
|
57
78
|
logger = console,
|
|
58
79
|
replyTimeoutMs = 600_000,
|
|
59
80
|
maxMessageChars = 4_000,
|
|
81
|
+
startRetryDelaysMs,
|
|
60
82
|
}) {
|
|
61
83
|
if (!api || !config || !token || !harness || !state) {
|
|
62
84
|
throw new TypeError('WeixinRuntime requires API, account, token, Harness, and state');
|
|
@@ -69,6 +91,7 @@ export class WeixinRuntime {
|
|
|
69
91
|
this.#logger = logger;
|
|
70
92
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
71
93
|
this.#maxMessageChars = maxMessageChars;
|
|
94
|
+
this.#startRetryDelaysMs = startRetryDelays(startRetryDelaysMs);
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
get status() {
|
|
@@ -92,10 +115,9 @@ export class WeixinRuntime {
|
|
|
92
115
|
try {
|
|
93
116
|
await this.#harness.ensureRunning();
|
|
94
117
|
this.#status.harnessReachable = true;
|
|
95
|
-
await this.#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
118
|
+
await this.#notifyStart();
|
|
119
|
+
this.#abortController = new AbortController();
|
|
120
|
+
const signal = this.#abortController.signal;
|
|
99
121
|
this.#bridge = new WeixinHarnessBridge({
|
|
100
122
|
api: this.#api,
|
|
101
123
|
baseUrl: this.#config.baseUrl,
|
|
@@ -107,12 +129,11 @@ export class WeixinRuntime {
|
|
|
107
129
|
logger: this.#logger,
|
|
108
130
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
109
131
|
maxMessageChars: this.#maxMessageChars,
|
|
132
|
+
signal,
|
|
110
133
|
});
|
|
111
|
-
this.#abortController = new AbortController();
|
|
112
134
|
this.#status.ready = true;
|
|
113
135
|
this.#status.weixinConnectionState = 'connected';
|
|
114
136
|
this.#status.lastCheckedAt = Date.now();
|
|
115
|
-
const signal = this.#abortController.signal;
|
|
116
137
|
this.#monitor = this.#runMonitor(signal).catch((error) => {
|
|
117
138
|
if (signal.aborted) return;
|
|
118
139
|
this.#status.ready = false;
|
|
@@ -122,6 +143,9 @@ export class WeixinRuntime {
|
|
|
122
143
|
});
|
|
123
144
|
return this.status;
|
|
124
145
|
} catch (error) {
|
|
146
|
+
this.#abortController?.abort();
|
|
147
|
+
this.#abortController = null;
|
|
148
|
+
this.#bridge = null;
|
|
125
149
|
this.#status.ready = false;
|
|
126
150
|
this.#status.weixinConnectionState = 'failed';
|
|
127
151
|
this.#status.lastError = error?.message ?? String(error);
|
|
@@ -129,6 +153,25 @@ export class WeixinRuntime {
|
|
|
129
153
|
}
|
|
130
154
|
}
|
|
131
155
|
|
|
156
|
+
async #notifyStart() {
|
|
157
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
158
|
+
try {
|
|
159
|
+
return await this.#api.notifyStart({
|
|
160
|
+
baseUrl: this.#config.baseUrl,
|
|
161
|
+
token: this.#token,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const wait = this.#startRetryDelaysMs[attempt];
|
|
165
|
+
if (wait === undefined || !retryableStartError(error)) throw error;
|
|
166
|
+
this.#logger.warn?.(
|
|
167
|
+
`[dsh-weixin] account ${this.#config.botId} start request failed; retrying in ${wait}ms:`,
|
|
168
|
+
error,
|
|
169
|
+
);
|
|
170
|
+
await delay(wait);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
async #runMonitor(signal) {
|
|
133
176
|
let consecutiveFailures = 0;
|
|
134
177
|
while (!signal.aborted) {
|
|
@@ -156,7 +199,13 @@ export class WeixinRuntime {
|
|
|
156
199
|
this.#status.lastError = null;
|
|
157
200
|
|
|
158
201
|
for (const message of response?.msgs ?? []) {
|
|
159
|
-
|
|
202
|
+
void this.#bridge.accept(message).catch((error) => {
|
|
203
|
+
if (signal.aborted) return;
|
|
204
|
+
this.#logger.error?.(
|
|
205
|
+
`[dsh-weixin] account ${this.#config.botId} message handling failed:`,
|
|
206
|
+
error,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
160
209
|
}
|
|
161
210
|
if (typeof response?.get_updates_buf === 'string' && response.get_updates_buf) {
|
|
162
211
|
await this.#state.setGetUpdatesBuf(response.get_updates_buf);
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
import { HarnessClient } from '../
|
|
1
|
+
import { HarnessClient } from '../shared/harness-client.mjs';
|
|
2
2
|
|
|
3
|
-
export class WhatsappHarnessClient extends HarnessClient {
|
|
3
|
+
export class WhatsappHarnessClient extends HarnessClient {
|
|
4
|
+
constructor(options) {
|
|
5
|
+
super({
|
|
6
|
+
...options,
|
|
7
|
+
rpcIdPrefix: 'whatsapp',
|
|
8
|
+
logPrefix: 'dsh-whatsapp',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|