@xmanrui/dsh-im 0.7.0 → 0.7.2
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/lib/index.js +119 -121
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +423 -8
- package/src/channels/dingtalk/harness-client.mjs +16 -366
- 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 +571 -50
- package/src/channels/feishu/feishu-runtime.mjs +41 -1
- package/src/channels/feishu/harness-client.mjs +16 -335
- package/src/channels/qq/harness-client.mjs +10 -2
- package/src/channels/qq/qq-bridge.mjs +428 -28
- package/src/channels/qq/qq-runtime.mjs +14 -3
- package/src/channels/shared/harness-approval.mjs +472 -0
- package/src/channels/shared/harness-client.mjs +858 -0
- package/src/channels/shared/harness-question.mjs +85 -0
- package/src/channels/shared/text-harness-bridge.mjs +486 -24
- 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 +434 -14
- package/src/channels/wecom/wecom-runtime.mjs +6 -0
- package/src/channels/weixin/harness-client.mjs +16 -326
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +451 -22
- 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,17 @@ 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';
|
|
11
|
+
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
6
12
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
7
13
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
8
14
|
|
|
15
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
16
|
+
|
|
9
17
|
const HELP_TEXT = [
|
|
10
18
|
'微信已连接 DeepSeek Harness。',
|
|
11
19
|
'',
|
|
@@ -23,6 +31,16 @@ function conversationKey(userId) {
|
|
|
23
31
|
return `p2p:${userId}`;
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
function nonEmptyString(value) {
|
|
35
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function canClaimInteractionReply(message, pending) {
|
|
39
|
+
return pending.questions[pending.index]
|
|
40
|
+
&& nonEmptyString(message?.from_user_id) === pending.actor
|
|
41
|
+
&& nonEmptyString(extractWeixinText(message));
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export function createWeixinBridgeStatus() {
|
|
27
45
|
return {
|
|
28
46
|
messagesReceived: 0,
|
|
@@ -46,7 +64,13 @@ export class WeixinHarnessBridge {
|
|
|
46
64
|
#logger;
|
|
47
65
|
#replyTimeoutMs;
|
|
48
66
|
#maxMessageChars;
|
|
67
|
+
#signal;
|
|
49
68
|
#queues = new Map();
|
|
69
|
+
#pendingInteractions = new Map();
|
|
70
|
+
#interactionKeys = new Map();
|
|
71
|
+
#acceptedMessageIds = new Set();
|
|
72
|
+
#approvalTasks = new Set();
|
|
73
|
+
#approvals;
|
|
50
74
|
|
|
51
75
|
constructor({
|
|
52
76
|
api,
|
|
@@ -59,6 +83,7 @@ export class WeixinHarnessBridge {
|
|
|
59
83
|
logger = console,
|
|
60
84
|
replyTimeoutMs = 600_000,
|
|
61
85
|
maxMessageChars = 4_000,
|
|
86
|
+
signal,
|
|
62
87
|
}) {
|
|
63
88
|
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
|
|
64
89
|
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
|
|
@@ -73,6 +98,8 @@ export class WeixinHarnessBridge {
|
|
|
73
98
|
this.#logger = logger;
|
|
74
99
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
75
100
|
this.#maxMessageChars = maxMessageChars;
|
|
101
|
+
this.#signal = signal;
|
|
102
|
+
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
get status() {
|
|
@@ -80,31 +107,105 @@ export class WeixinHarnessBridge {
|
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
accept(message) {
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
111
|
+
if (message?.message_type === 2) return Promise.resolve();
|
|
112
|
+
const messageId = weixinMessageId(message);
|
|
113
|
+
const sender = nonEmptyString(message?.from_user_id);
|
|
114
|
+
if (!messageId || !sender || this.#state.hasSeen(messageId)
|
|
115
|
+
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
116
|
+
this.#acceptedMessageIds.add(messageId);
|
|
117
|
+
const key = conversationKey(sender);
|
|
118
|
+
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
119
|
+
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
120
|
+
const pending = this.#pendingInteractions.get(key);
|
|
121
|
+
const approval = this.#approvals.claimReply({
|
|
122
|
+
key,
|
|
123
|
+
actor: sender,
|
|
124
|
+
messageId,
|
|
125
|
+
text: extractWeixinText(message),
|
|
126
|
+
addressed: true,
|
|
127
|
+
hasPendingQuestion: Boolean(pending),
|
|
128
|
+
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
129
|
+
? pending.queue
|
|
130
|
+
: null,
|
|
131
|
+
isQuestionPending: () => this.#pendingInteractions.has(key),
|
|
132
|
+
send: (text) => this.#send(sender, text, contextToken, runId),
|
|
133
|
+
});
|
|
134
|
+
if (approval) {
|
|
135
|
+
let task;
|
|
136
|
+
task = approval.process(async () => {
|
|
137
|
+
if (this.#state.hasSeen(messageId)) return false;
|
|
138
|
+
await this.#state.markSeen(messageId);
|
|
139
|
+
this.#status.messagesReceived += 1;
|
|
140
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
141
|
+
return true;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
145
|
+
this.#approvalTasks.delete(task);
|
|
146
|
+
});
|
|
147
|
+
this.#approvalTasks.add(task);
|
|
148
|
+
return task;
|
|
149
|
+
}
|
|
150
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
151
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
152
|
+
}
|
|
153
|
+
if (pending) {
|
|
154
|
+
if (canClaimInteractionReply(message, pending)) {
|
|
155
|
+
pending.claimedReplyMessageId = messageId;
|
|
156
|
+
}
|
|
157
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
158
|
+
const current = previous
|
|
159
|
+
.catch(() => undefined)
|
|
160
|
+
.then(() => this.#processInteractionReply(message, messageId, key, pending))
|
|
161
|
+
.catch((error) => this.#handleInteractionFailure(message, messageId, error))
|
|
162
|
+
.finally(() => {
|
|
163
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
164
|
+
if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
|
|
165
|
+
if (pending.queue === current) pending.queue = null;
|
|
166
|
+
});
|
|
167
|
+
pending.queue = current;
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#enqueueMessage(message, messageId, key, {
|
|
174
|
+
releaseMessageId = true,
|
|
175
|
+
alreadyRecorded = false,
|
|
176
|
+
} = {}) {
|
|
177
|
+
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
85
178
|
const current = previous
|
|
86
179
|
.catch(() => undefined)
|
|
87
|
-
.then(() => this.#process(message))
|
|
180
|
+
.then(() => this.#process(message, key, { alreadyRecorded }))
|
|
88
181
|
.finally(() => {
|
|
89
|
-
if (
|
|
182
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
183
|
+
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
90
184
|
});
|
|
91
|
-
this.#queues.set(
|
|
185
|
+
this.#queues.set(key, current);
|
|
92
186
|
return current;
|
|
93
187
|
}
|
|
94
188
|
|
|
95
189
|
async waitForIdle() {
|
|
96
|
-
await Promise.allSettled([
|
|
190
|
+
await Promise.allSettled([
|
|
191
|
+
...this.#queues.values(),
|
|
192
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
193
|
+
pending.queue ? [pending.queue] : []
|
|
194
|
+
)),
|
|
195
|
+
...this.#approvalTasks,
|
|
196
|
+
]);
|
|
97
197
|
}
|
|
98
198
|
|
|
99
|
-
async #process(message) {
|
|
100
|
-
|
|
199
|
+
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
200
|
+
this.#signal?.throwIfAborted();
|
|
101
201
|
const messageId = weixinMessageId(message);
|
|
102
|
-
const sender =
|
|
202
|
+
const sender = nonEmptyString(message?.from_user_id);
|
|
103
203
|
if (!messageId || !sender) return;
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
204
|
+
if (!alreadyRecorded) {
|
|
205
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
206
|
+
this.#status.messagesReceived += 1;
|
|
207
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
208
|
+
}
|
|
108
209
|
if (sender !== this.#ownerUserId) {
|
|
109
210
|
this.#status.messagesRejected += 1;
|
|
110
211
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
@@ -122,14 +223,13 @@ export class WeixinHarnessBridge {
|
|
|
122
223
|
}
|
|
123
224
|
|
|
124
225
|
const command = text.trim().toLowerCase();
|
|
125
|
-
const key = conversationKey(sender);
|
|
126
226
|
if (command === '/help') {
|
|
127
227
|
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
|
128
228
|
await this.#state.markSeen(messageId);
|
|
129
229
|
return;
|
|
130
230
|
}
|
|
131
231
|
if (command === '/status') {
|
|
132
|
-
await this.#harness.ensureRunning();
|
|
232
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
133
233
|
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
|
134
234
|
await this.#state.markSeen(messageId);
|
|
135
235
|
return;
|
|
@@ -149,19 +249,40 @@ export class WeixinHarnessBridge {
|
|
|
149
249
|
return;
|
|
150
250
|
}
|
|
151
251
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
252
|
+
let answer;
|
|
253
|
+
try {
|
|
254
|
+
({ answer } = await askInWorkspaceSession({
|
|
255
|
+
harness: this.#harness,
|
|
256
|
+
state: this.#state,
|
|
257
|
+
key,
|
|
258
|
+
text,
|
|
259
|
+
createOptions: { signal: this.#signal },
|
|
260
|
+
existsOptions: { signal: this.#signal },
|
|
261
|
+
askOptions: {
|
|
262
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
263
|
+
signal: this.#signal,
|
|
264
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
265
|
+
key,
|
|
266
|
+
actor: sender,
|
|
267
|
+
contextToken,
|
|
268
|
+
runId,
|
|
269
|
+
}),
|
|
270
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
271
|
+
},
|
|
272
|
+
}));
|
|
273
|
+
} finally {
|
|
274
|
+
await Promise.allSettled([
|
|
275
|
+
this.#cancelPendingInteraction(key),
|
|
276
|
+
this.#approvals.closeRoute(key),
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
159
279
|
await this.#send(sender, answer, contextToken, runId);
|
|
160
280
|
await this.#state.markSeen(messageId);
|
|
161
281
|
this.#status.messagesReplied += 1;
|
|
162
282
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
163
283
|
this.#status.lastError = null;
|
|
164
284
|
} catch (error) {
|
|
285
|
+
if (this.#signal?.aborted) return;
|
|
165
286
|
this.#status.lastError = error?.message ?? String(error);
|
|
166
287
|
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
|
167
288
|
try {
|
|
@@ -173,6 +294,314 @@ export class WeixinHarnessBridge {
|
|
|
173
294
|
}
|
|
174
295
|
}
|
|
175
296
|
|
|
297
|
+
async #processInteractionReply(message, messageId, key, expected) {
|
|
298
|
+
this.#signal?.throwIfAborted();
|
|
299
|
+
const current = this.#pendingInteractions.get(key);
|
|
300
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
301
|
+
if (!current || current !== expected || current.submitting) {
|
|
302
|
+
if (claimed && (!current || current !== expected)) {
|
|
303
|
+
return this.#discardResolvedInteractionReply(message, messageId);
|
|
304
|
+
}
|
|
305
|
+
return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
|
|
306
|
+
}
|
|
307
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
308
|
+
await this.#state.markSeen(messageId);
|
|
309
|
+
this.#status.messagesReceived += 1;
|
|
310
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
311
|
+
|
|
312
|
+
const text = nonEmptyString(extractWeixinText(message));
|
|
313
|
+
const contextToken = nonEmptyString(message?.context_token) ?? undefined;
|
|
314
|
+
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
|
315
|
+
if (!text) {
|
|
316
|
+
await this.#send(
|
|
317
|
+
expected.actor,
|
|
318
|
+
'请用文字回答当前问题。',
|
|
319
|
+
contextToken,
|
|
320
|
+
runId,
|
|
321
|
+
);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const pending = this.#pendingInteractions.get(key);
|
|
326
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
327
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
328
|
+
await this.#send(
|
|
329
|
+
expected.actor,
|
|
330
|
+
INTERACTION_RESOLVED_TEXT,
|
|
331
|
+
contextToken,
|
|
332
|
+
runId,
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
337
|
+
releaseMessageId: false,
|
|
338
|
+
alreadyRecorded: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
pending.contextToken = contextToken;
|
|
342
|
+
pending.runId = runId;
|
|
343
|
+
if (pending.needsPresentation) {
|
|
344
|
+
try {
|
|
345
|
+
await this.#presentInteraction(pending);
|
|
346
|
+
} catch {
|
|
347
|
+
this.#status.lastError = '微信交互问题发送失败。';
|
|
348
|
+
this.#logger.error?.('[dsh-weixin] failed to retry an interaction question');
|
|
349
|
+
pending.interaction.reconnect?.();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const presentedPending = this.#pendingInteractions.get(key);
|
|
353
|
+
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
|
|
354
|
+
if (claimed && (!presentedPending || presentedPending !== expected)) {
|
|
355
|
+
await this.#send(
|
|
356
|
+
expected.actor,
|
|
357
|
+
INTERACTION_RESOLVED_TEXT,
|
|
358
|
+
contextToken,
|
|
359
|
+
runId,
|
|
360
|
+
).catch(() => undefined);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
364
|
+
releaseMessageId: false,
|
|
365
|
+
alreadyRecorded: true,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const question = pending.questions[pending.index];
|
|
371
|
+
if (!question) return;
|
|
372
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
373
|
+
pending.index += 1;
|
|
374
|
+
if (pending.index < pending.questions.length) {
|
|
375
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
376
|
+
pending.claimedReplyMessageId = null;
|
|
377
|
+
}
|
|
378
|
+
pending.needsPresentation = true;
|
|
379
|
+
try {
|
|
380
|
+
await this.#presentInteraction(pending);
|
|
381
|
+
} catch {
|
|
382
|
+
this.#status.lastError = '微信交互问题发送失败。';
|
|
383
|
+
this.#logger.error?.('[dsh-weixin] failed to send the next interaction question');
|
|
384
|
+
pending.interaction.reconnect?.();
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
pending.submitting = true;
|
|
390
|
+
try {
|
|
391
|
+
await pending.interaction.respond({
|
|
392
|
+
ok: true,
|
|
393
|
+
value: {
|
|
394
|
+
sessionId: pending.sessionId,
|
|
395
|
+
answer: { answers: pending.answers },
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
399
|
+
this.#status.lastError = null;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (this.#signal?.aborted) return;
|
|
402
|
+
if (error?.code === 'interaction-not-pending') {
|
|
403
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
404
|
+
await this.#send(
|
|
405
|
+
pending.actor,
|
|
406
|
+
INTERACTION_RESOLVED_TEXT,
|
|
407
|
+
pending.contextToken,
|
|
408
|
+
pending.runId,
|
|
409
|
+
).catch(() => undefined);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
413
|
+
pending.submitting = false;
|
|
414
|
+
pending.answers.pop();
|
|
415
|
+
pending.index -= 1;
|
|
416
|
+
this.#status.lastError = '回答提交失败。';
|
|
417
|
+
this.#logger.error?.('[dsh-weixin] failed to answer a Harness interaction');
|
|
418
|
+
await this.#send(
|
|
419
|
+
pending.actor,
|
|
420
|
+
'回答提交失败,请重新发送当前问题的答案。',
|
|
421
|
+
pending.contextToken,
|
|
422
|
+
pending.runId,
|
|
423
|
+
).catch(() => undefined);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async #handleInteraction(interaction, {
|
|
428
|
+
key,
|
|
429
|
+
actor,
|
|
430
|
+
contextToken,
|
|
431
|
+
runId,
|
|
432
|
+
}) {
|
|
433
|
+
if (interaction?.kind === 'approval') {
|
|
434
|
+
return this.#approvals.handleRequested(interaction, {
|
|
435
|
+
key,
|
|
436
|
+
actor,
|
|
437
|
+
send: (text) => this.#send(actor, text, contextToken, runId),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (interaction?.kind !== 'question') return;
|
|
441
|
+
const questions = interaction?.payload?.questions;
|
|
442
|
+
const interactionId = typeof interaction?.interactionId === 'string'
|
|
443
|
+
? interaction.interactionId
|
|
444
|
+
: interaction?.rpcId;
|
|
445
|
+
if (typeof interaction?.rpcId !== 'string'
|
|
446
|
+
|| typeof interactionId !== 'string'
|
|
447
|
+
|| typeof interaction.sessionId !== 'string'
|
|
448
|
+
|| !Array.isArray(questions)
|
|
449
|
+
|| questions.length === 0
|
|
450
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
451
|
+
this.#logger.warn?.('[dsh-weixin] ignored an invalid Harness question interaction');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (interaction.recovered === true) {
|
|
456
|
+
await interaction.respond({
|
|
457
|
+
ok: false,
|
|
458
|
+
error: {
|
|
459
|
+
code: 'cancelled',
|
|
460
|
+
message: 'Weixin safely cancelled an interaction left by an earlier client.',
|
|
461
|
+
details: {},
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
await this.#send(
|
|
465
|
+
actor,
|
|
466
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
467
|
+
contextToken,
|
|
468
|
+
runId,
|
|
469
|
+
).catch(() => undefined);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const existing = this.#pendingInteractions.get(key);
|
|
474
|
+
if (existing?.interactionId === interactionId) {
|
|
475
|
+
existing.interaction = interaction;
|
|
476
|
+
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (this.#interactionKeys.has(interactionId)) return;
|
|
480
|
+
if (existing) {
|
|
481
|
+
await interaction.respond({
|
|
482
|
+
ok: false,
|
|
483
|
+
error: {
|
|
484
|
+
code: 'cancelled',
|
|
485
|
+
message: 'Weixin is already handling another user interaction.',
|
|
486
|
+
details: {},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const pending = {
|
|
493
|
+
kind: 'question',
|
|
494
|
+
interactionId,
|
|
495
|
+
sessionId: interaction.sessionId,
|
|
496
|
+
interaction,
|
|
497
|
+
actor,
|
|
498
|
+
questions,
|
|
499
|
+
answers: [],
|
|
500
|
+
index: 0,
|
|
501
|
+
contextToken,
|
|
502
|
+
runId,
|
|
503
|
+
queue: null,
|
|
504
|
+
claimedReplyMessageId: null,
|
|
505
|
+
presentationPromise: null,
|
|
506
|
+
submitting: false,
|
|
507
|
+
needsPresentation: true,
|
|
508
|
+
};
|
|
509
|
+
this.#pendingInteractions.set(key, pending);
|
|
510
|
+
this.#interactionKeys.set(interactionId, key);
|
|
511
|
+
await this.#presentInteraction(pending);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async #handleInteractionResolved(resolution) {
|
|
515
|
+
if (resolution?.kind === 'approval') {
|
|
516
|
+
await this.#approvals.handleResolved(resolution);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const interactionId = resolution?.interactionId;
|
|
520
|
+
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
|
521
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
522
|
+
if (!key) return;
|
|
523
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#presentInteraction(pending) {
|
|
527
|
+
if (!pending.needsPresentation) return Promise.resolve();
|
|
528
|
+
if (pending.presentationPromise) return pending.presentationPromise;
|
|
529
|
+
const question = pending.questions[pending.index];
|
|
530
|
+
if (!question) return Promise.resolve();
|
|
531
|
+
const presentation = this.#send(
|
|
532
|
+
pending.actor,
|
|
533
|
+
harnessQuestionText(question, pending.index, pending.questions.length),
|
|
534
|
+
pending.contextToken,
|
|
535
|
+
pending.runId,
|
|
536
|
+
).then(() => {
|
|
537
|
+
pending.needsPresentation = false;
|
|
538
|
+
}).finally(() => {
|
|
539
|
+
if (pending.presentationPromise === presentation) pending.presentationPromise = null;
|
|
540
|
+
});
|
|
541
|
+
pending.presentationPromise = presentation;
|
|
542
|
+
return presentation;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async #discardResolvedInteractionReply(message, messageId) {
|
|
546
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
547
|
+
await this.#state.markSeen(messageId);
|
|
548
|
+
this.#status.messagesReceived += 1;
|
|
549
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
550
|
+
await this.#send(
|
|
551
|
+
nonEmptyString(message?.from_user_id),
|
|
552
|
+
INTERACTION_RESOLVED_TEXT,
|
|
553
|
+
nonEmptyString(message?.context_token) ?? undefined,
|
|
554
|
+
nonEmptyString(message?.run_id) ?? undefined,
|
|
555
|
+
).catch(() => undefined);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#takePendingInteraction(key, interactionId) {
|
|
559
|
+
const pending = this.#pendingInteractions.get(key);
|
|
560
|
+
if (!pending
|
|
561
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
562
|
+
this.#pendingInteractions.delete(key);
|
|
563
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
564
|
+
return pending;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#clearPendingInteraction(key, interactionId) {
|
|
568
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async #cancelPendingInteraction(key) {
|
|
572
|
+
const pending = this.#takePendingInteraction(key);
|
|
573
|
+
if (!pending || pending.kind !== 'question') return;
|
|
574
|
+
try {
|
|
575
|
+
await pending.interaction.respond({
|
|
576
|
+
ok: false,
|
|
577
|
+
error: {
|
|
578
|
+
code: 'cancelled',
|
|
579
|
+
message: 'The Weixin interaction ended before the user answered.',
|
|
580
|
+
details: {},
|
|
581
|
+
},
|
|
582
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
585
|
+
this.#logger.warn?.('[dsh-weixin] failed to cancel a pending Harness interaction');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async #handleInteractionFailure(message, messageId, error) {
|
|
591
|
+
if (this.#signal?.aborted) return;
|
|
592
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
593
|
+
this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
|
|
594
|
+
if (!this.#state.hasSeen(messageId)) {
|
|
595
|
+
await this.#state.markSeen(messageId).catch(() => undefined);
|
|
596
|
+
}
|
|
597
|
+
await this.#send(
|
|
598
|
+
nonEmptyString(message?.from_user_id),
|
|
599
|
+
'消息处理失败,请稍后重试。',
|
|
600
|
+
nonEmptyString(message?.context_token) ?? undefined,
|
|
601
|
+
nonEmptyString(message?.run_id) ?? undefined,
|
|
602
|
+
).catch(() => undefined);
|
|
603
|
+
}
|
|
604
|
+
|
|
176
605
|
async #send(toUserId, text, contextToken, runId) {
|
|
177
606
|
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
|
178
607
|
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
|
+
}
|