@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
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { generateReqId } from '@wecom/aibot-node-sdk';
|
|
2
|
+
import {
|
|
3
|
+
harnessAnswerForQuestion,
|
|
4
|
+
harnessQuestionText,
|
|
5
|
+
validHarnessQuestion,
|
|
6
|
+
} from '../shared/harness-question.mjs';
|
|
2
7
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
3
8
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
4
9
|
|
|
@@ -9,10 +14,17 @@ const HELP_TEXT = [
|
|
|
9
14
|
'/new 开启一个全新会话',
|
|
10
15
|
'/workspace 工作区绝对路径 切换工作区',
|
|
11
16
|
'/workspacelist 列出工作区绝对路径',
|
|
17
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
18
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
12
19
|
'/status 检查连接状态',
|
|
13
20
|
'/help 显示本帮助',
|
|
14
21
|
].join('\n');
|
|
15
22
|
const MAX_REPLY_BYTES = 18_000;
|
|
23
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
24
|
+
|
|
25
|
+
function nonEmptyString(value) {
|
|
26
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
27
|
+
}
|
|
16
28
|
|
|
17
29
|
function bodyOf(frame) {
|
|
18
30
|
return frame?.body && typeof frame.body === 'object' ? frame.body : {};
|
|
@@ -25,16 +37,23 @@ function conversationKey(frame) {
|
|
|
25
37
|
|
|
26
38
|
function messageText(frame) {
|
|
27
39
|
const body = bodyOf(frame);
|
|
28
|
-
|
|
29
|
-
if (body.msgtype === '
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
let text = '';
|
|
41
|
+
if (body.msgtype === 'text') {
|
|
42
|
+
text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
|
|
43
|
+
} else if (body.msgtype === 'voice') {
|
|
44
|
+
text = typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
|
|
45
|
+
} else if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
|
|
46
|
+
text = body.mixed.msg_item
|
|
32
47
|
.filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
|
|
33
48
|
.map((item) => item.text.content)
|
|
34
49
|
.join('\n')
|
|
35
50
|
.trim();
|
|
36
51
|
}
|
|
37
|
-
|
|
52
|
+
// Group callbacks retain the leading @bot mention that caused delivery.
|
|
53
|
+
// It is routing metadata rather than part of the user's prompt or answer.
|
|
54
|
+
return body.chattype === 'group'
|
|
55
|
+
? text.replace(/^\s*@\S+(?:\s+|$)/u, '').trim()
|
|
56
|
+
: text;
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
|
|
@@ -64,6 +83,12 @@ function progressText(update) {
|
|
|
64
83
|
return update?.text;
|
|
65
84
|
}
|
|
66
85
|
|
|
86
|
+
function canClaimInteractionReply(frame, pending) {
|
|
87
|
+
return pending.questions[pending.index]
|
|
88
|
+
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
|
89
|
+
&& nonEmptyString(messageText(frame));
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
export function createWecomBridgeStatus() {
|
|
68
93
|
return {
|
|
69
94
|
messagesReceived: 0,
|
|
@@ -84,7 +109,11 @@ export class WecomHarnessBridge {
|
|
|
84
109
|
#logger;
|
|
85
110
|
#replyTimeoutMs;
|
|
86
111
|
#generateReqId;
|
|
112
|
+
#signal;
|
|
87
113
|
#queues = new Map();
|
|
114
|
+
#pendingInteractions = new Map();
|
|
115
|
+
#interactionKeys = new Map();
|
|
116
|
+
#acceptedMessageIds = new Set();
|
|
88
117
|
|
|
89
118
|
constructor({
|
|
90
119
|
client,
|
|
@@ -94,6 +123,7 @@ export class WecomHarnessBridge {
|
|
|
94
123
|
logger = console,
|
|
95
124
|
replyTimeoutMs = 600_000,
|
|
96
125
|
generateStreamId = generateReqId,
|
|
126
|
+
signal,
|
|
97
127
|
}) {
|
|
98
128
|
if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
|
|
99
129
|
throw new TypeError('Enterprise WeChat client is required');
|
|
@@ -106,6 +136,7 @@ export class WecomHarnessBridge {
|
|
|
106
136
|
this.#logger = logger;
|
|
107
137
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
108
138
|
this.#generateReqId = generateStreamId;
|
|
139
|
+
this.#signal = signal;
|
|
109
140
|
}
|
|
110
141
|
|
|
111
142
|
get status() {
|
|
@@ -113,12 +144,65 @@ export class WecomHarnessBridge {
|
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
accept(frame) {
|
|
147
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
148
|
+
const body = bodyOf(frame);
|
|
149
|
+
const messageId = nonEmptyString(body.msgid);
|
|
150
|
+
const senderId = nonEmptyString(body.from?.userid);
|
|
151
|
+
const chatId = body.chattype === 'group'
|
|
152
|
+
? nonEmptyString(body.chatid)
|
|
153
|
+
: senderId;
|
|
154
|
+
if (!messageId || !senderId || !chatId
|
|
155
|
+
|| !['single', 'group'].includes(body.chattype)
|
|
156
|
+
|| this.#state.hasSeen(messageId)
|
|
157
|
+
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
158
|
+
|
|
116
159
|
const key = conversationKey(frame);
|
|
160
|
+
this.#acceptedMessageIds.add(messageId);
|
|
161
|
+
const pending = this.#pendingInteractions.get(key);
|
|
162
|
+
if (pending && pending.actor !== senderId) {
|
|
163
|
+
return this.#enqueueMessage(frame, messageId, key);
|
|
164
|
+
}
|
|
165
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
166
|
+
return this.#enqueueMessage(frame, messageId, key);
|
|
167
|
+
}
|
|
168
|
+
if (pending) {
|
|
169
|
+
if (canClaimInteractionReply(frame, pending)) {
|
|
170
|
+
pending.claimedReplyMessageId = messageId;
|
|
171
|
+
}
|
|
172
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
173
|
+
const current = previous
|
|
174
|
+
.catch(() => undefined)
|
|
175
|
+
.then(() => this.#processInteractionReply(
|
|
176
|
+
frame,
|
|
177
|
+
messageId,
|
|
178
|
+
senderId,
|
|
179
|
+
chatId,
|
|
180
|
+
key,
|
|
181
|
+
pending,
|
|
182
|
+
))
|
|
183
|
+
.finally(() => {
|
|
184
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
185
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
186
|
+
pending.claimedReplyMessageId = null;
|
|
187
|
+
}
|
|
188
|
+
if (pending.queue === current) pending.queue = null;
|
|
189
|
+
});
|
|
190
|
+
pending.queue = current;
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
return this.#enqueueMessage(frame, messageId, key);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#enqueueMessage(frame, messageId, key, {
|
|
197
|
+
releaseMessageId = true,
|
|
198
|
+
alreadyRecorded = false,
|
|
199
|
+
} = {}) {
|
|
117
200
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
118
201
|
const current = previous
|
|
119
202
|
.catch(() => undefined)
|
|
120
|
-
.then(() => this.#process(frame))
|
|
203
|
+
.then(() => this.#process(frame, { alreadyRecorded }))
|
|
121
204
|
.finally(() => {
|
|
205
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
122
206
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
123
207
|
});
|
|
124
208
|
this.#queues.set(key, current);
|
|
@@ -126,16 +210,23 @@ export class WecomHarnessBridge {
|
|
|
126
210
|
}
|
|
127
211
|
|
|
128
212
|
async waitForIdle() {
|
|
129
|
-
await Promise.allSettled([
|
|
213
|
+
await Promise.allSettled([
|
|
214
|
+
...this.#queues.values(),
|
|
215
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
216
|
+
pending.queue ? [pending.queue] : []
|
|
217
|
+
)),
|
|
218
|
+
]);
|
|
130
219
|
}
|
|
131
220
|
|
|
132
221
|
async #sendActive(chatId, text) {
|
|
133
222
|
for (const chunk of splitUtf8(text)) {
|
|
223
|
+
this.#signal?.throwIfAborted();
|
|
134
224
|
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
|
|
135
225
|
}
|
|
136
226
|
}
|
|
137
227
|
|
|
138
228
|
async #sendImmediate(frame, chatId, text) {
|
|
229
|
+
this.#signal?.throwIfAborted();
|
|
139
230
|
const chunks = splitUtf8(text);
|
|
140
231
|
if (chunks.length === 0) return;
|
|
141
232
|
try {
|
|
@@ -148,17 +239,20 @@ export class WecomHarnessBridge {
|
|
|
148
239
|
}
|
|
149
240
|
}
|
|
150
241
|
|
|
151
|
-
async #process(frame) {
|
|
242
|
+
async #process(frame, { alreadyRecorded = false } = {}) {
|
|
243
|
+
if (this.#signal?.aborted) return;
|
|
152
244
|
const body = bodyOf(frame);
|
|
153
245
|
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
|
|
154
246
|
const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
|
|
155
247
|
const chatId = body.chattype === 'group' ? body.chatid : senderId;
|
|
156
248
|
if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return;
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
249
|
+
if (!alreadyRecorded) {
|
|
250
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
251
|
+
this.#status.messagesReceived += 1;
|
|
252
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
253
|
+
}
|
|
161
254
|
const text = messageText(frame);
|
|
255
|
+
const key = conversationKey(frame);
|
|
162
256
|
let streamId = null;
|
|
163
257
|
let streamStarted = false;
|
|
164
258
|
try {
|
|
@@ -174,19 +268,18 @@ export class WecomHarnessBridge {
|
|
|
174
268
|
return;
|
|
175
269
|
}
|
|
176
270
|
if (command === '/status') {
|
|
177
|
-
await this.#harness.ensureRunning();
|
|
271
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
178
272
|
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
|
179
273
|
await this.#state.markSeen(messageId);
|
|
180
274
|
return;
|
|
181
275
|
}
|
|
182
|
-
const key = conversationKey(frame);
|
|
183
276
|
if (command === '/new') {
|
|
184
277
|
await this.#state.clearSession(key);
|
|
185
278
|
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
|
186
279
|
await this.#state.markSeen(messageId);
|
|
187
280
|
return;
|
|
188
281
|
}
|
|
189
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
282
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
190
283
|
if (workspaceCommand) {
|
|
191
284
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
192
285
|
await this.#sendImmediate(frame, chatId, reply);
|
|
@@ -208,14 +301,24 @@ export class WecomHarnessBridge {
|
|
|
208
301
|
state: this.#state,
|
|
209
302
|
key,
|
|
210
303
|
text,
|
|
304
|
+
createOptions: { signal: this.#signal },
|
|
305
|
+
existsOptions: { signal: this.#signal },
|
|
211
306
|
askOptions: {
|
|
212
307
|
timeoutMs: this.#replyTimeoutMs,
|
|
308
|
+
signal: this.#signal,
|
|
213
309
|
onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
|
|
214
310
|
? async (update) => {
|
|
215
311
|
const progress = splitUtf8(progressText(update))[0];
|
|
216
312
|
if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
|
|
217
313
|
}
|
|
218
314
|
: undefined,
|
|
315
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
316
|
+
key,
|
|
317
|
+
actor: senderId,
|
|
318
|
+
chatId,
|
|
319
|
+
requiresMention: body.chattype === 'group',
|
|
320
|
+
}),
|
|
321
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
219
322
|
},
|
|
220
323
|
});
|
|
221
324
|
|
|
@@ -238,6 +341,7 @@ export class WecomHarnessBridge {
|
|
|
238
341
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
239
342
|
this.#status.lastError = null;
|
|
240
343
|
} catch (error) {
|
|
344
|
+
if (this.#signal?.aborted) return;
|
|
241
345
|
this.#status.lastError = error?.message ?? String(error);
|
|
242
346
|
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
|
243
347
|
try {
|
|
@@ -250,6 +354,276 @@ export class WecomHarnessBridge {
|
|
|
250
354
|
} catch {
|
|
251
355
|
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
|
|
252
356
|
}
|
|
357
|
+
} finally {
|
|
358
|
+
await this.#cancelPendingInteraction(key);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async #processInteractionReply(frame, messageId, senderId, chatId, key, expected) {
|
|
363
|
+
if (this.#signal?.aborted) return;
|
|
364
|
+
const current = this.#pendingInteractions.get(key);
|
|
365
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
366
|
+
if (!current || current !== expected || current.submitting) {
|
|
367
|
+
if (claimed && (!current || current !== expected)) {
|
|
368
|
+
return this.#discardResolvedInteractionReply(frame, messageId, chatId);
|
|
369
|
+
}
|
|
370
|
+
return this.#enqueueMessage(frame, messageId, key, { releaseMessageId: false });
|
|
371
|
+
}
|
|
372
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
373
|
+
await this.#state.markSeen(messageId);
|
|
374
|
+
this.#status.messagesReceived += 1;
|
|
375
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
376
|
+
|
|
377
|
+
const text = nonEmptyString(messageText(frame));
|
|
378
|
+
if (!text) {
|
|
379
|
+
await this.#sendImmediate(frame, chatId, '请用文字或语音回答当前问题。')
|
|
380
|
+
.catch(() => undefined);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const pending = this.#pendingInteractions.get(key);
|
|
385
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
386
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
387
|
+
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
|
|
388
|
+
.catch(() => undefined);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
return this.#enqueueMessage(frame, messageId, key, {
|
|
392
|
+
releaseMessageId: false,
|
|
393
|
+
alreadyRecorded: true,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (pending.actor !== senderId) {
|
|
397
|
+
return this.#enqueueMessage(frame, messageId, key, {
|
|
398
|
+
releaseMessageId: false,
|
|
399
|
+
alreadyRecorded: true,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
pending.chatId = chatId;
|
|
404
|
+
if (pending.needsPresentation) {
|
|
405
|
+
try {
|
|
406
|
+
await this.#presentInteraction(pending);
|
|
407
|
+
} catch {
|
|
408
|
+
this.#status.lastError = '企业微信交互问题发送失败。';
|
|
409
|
+
this.#logger.error?.('[dsh-im:wecom] failed to retry an interaction question');
|
|
410
|
+
pending.interaction.reconnect?.();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const presentedPending = this.#pendingInteractions.get(key);
|
|
414
|
+
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
|
|
415
|
+
if (claimed && (!presentedPending || presentedPending !== expected)) {
|
|
416
|
+
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
|
|
417
|
+
.catch(() => undefined);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
return this.#enqueueMessage(frame, messageId, key, {
|
|
421
|
+
releaseMessageId: false,
|
|
422
|
+
alreadyRecorded: true,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const question = pending.questions[pending.index];
|
|
428
|
+
if (!question) return;
|
|
429
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
430
|
+
pending.index += 1;
|
|
431
|
+
if (pending.index < pending.questions.length) {
|
|
432
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
433
|
+
pending.claimedReplyMessageId = null;
|
|
434
|
+
}
|
|
435
|
+
pending.needsPresentation = true;
|
|
436
|
+
try {
|
|
437
|
+
await this.#presentInteraction(pending);
|
|
438
|
+
} catch {
|
|
439
|
+
this.#status.lastError = '企业微信交互问题发送失败。';
|
|
440
|
+
this.#logger.error?.('[dsh-im:wecom] failed to send the next interaction question');
|
|
441
|
+
pending.interaction.reconnect?.();
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
pending.submitting = true;
|
|
447
|
+
try {
|
|
448
|
+
await pending.interaction.respond({
|
|
449
|
+
ok: true,
|
|
450
|
+
value: {
|
|
451
|
+
sessionId: pending.sessionId,
|
|
452
|
+
answer: { answers: pending.answers },
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
456
|
+
this.#status.lastError = null;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (this.#signal?.aborted) return;
|
|
459
|
+
if (error?.code === 'interaction-not-pending') {
|
|
460
|
+
if (this.#pendingInteractions.get(key) === pending) {
|
|
461
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
462
|
+
}
|
|
463
|
+
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
|
|
464
|
+
.catch(() => undefined);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
468
|
+
pending.submitting = false;
|
|
469
|
+
pending.answers.pop();
|
|
470
|
+
pending.index -= 1;
|
|
471
|
+
this.#status.lastError = '回答提交失败。';
|
|
472
|
+
this.#logger.error?.('[dsh-im:wecom] failed to answer a Harness interaction');
|
|
473
|
+
await this.#sendImmediate(frame, chatId, '回答提交失败,请重新发送当前问题的答案。')
|
|
474
|
+
.catch(() => undefined);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async #handleInteraction(interaction, {
|
|
479
|
+
key,
|
|
480
|
+
actor,
|
|
481
|
+
chatId,
|
|
482
|
+
requiresMention,
|
|
483
|
+
}) {
|
|
484
|
+
// Approval remains unanswered until #5 supplies an authenticated policy.
|
|
485
|
+
if (interaction?.kind !== 'question') return;
|
|
486
|
+
const questions = interaction?.payload?.questions;
|
|
487
|
+
const interactionId = typeof interaction?.interactionId === 'string'
|
|
488
|
+
? interaction.interactionId
|
|
489
|
+
: interaction?.rpcId;
|
|
490
|
+
if (typeof interaction?.rpcId !== 'string'
|
|
491
|
+
|| typeof interactionId !== 'string'
|
|
492
|
+
|| typeof interaction.sessionId !== 'string'
|
|
493
|
+
|| !Array.isArray(questions)
|
|
494
|
+
|| questions.length === 0
|
|
495
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
496
|
+
this.#logger.warn?.('[dsh-im:wecom] ignored an invalid Harness question interaction');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (interaction.recovered === true) {
|
|
501
|
+
await interaction.respond({
|
|
502
|
+
ok: false,
|
|
503
|
+
error: {
|
|
504
|
+
code: 'cancelled',
|
|
505
|
+
message: 'Enterprise WeChat safely cancelled an interaction left by an earlier client.',
|
|
506
|
+
details: {},
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
await this.#sendActive(
|
|
510
|
+
chatId,
|
|
511
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
512
|
+
).catch(() => undefined);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const existing = this.#pendingInteractions.get(key);
|
|
517
|
+
if (existing?.interactionId === interactionId) {
|
|
518
|
+
existing.interaction = interaction;
|
|
519
|
+
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (this.#interactionKeys.has(interactionId)) return;
|
|
523
|
+
if (existing) {
|
|
524
|
+
this.#logger.warn?.('[dsh-im:wecom] cancelled a second pending Harness question');
|
|
525
|
+
await interaction.respond({
|
|
526
|
+
ok: false,
|
|
527
|
+
error: {
|
|
528
|
+
code: 'cancelled',
|
|
529
|
+
message: 'Enterprise WeChat is already handling another user interaction.',
|
|
530
|
+
details: {},
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const pending = {
|
|
537
|
+
kind: 'question',
|
|
538
|
+
interactionId,
|
|
539
|
+
sessionId: interaction.sessionId,
|
|
540
|
+
interaction,
|
|
541
|
+
actor,
|
|
542
|
+
requiresMention,
|
|
543
|
+
questions,
|
|
544
|
+
answers: [],
|
|
545
|
+
index: 0,
|
|
546
|
+
chatId,
|
|
547
|
+
queue: null,
|
|
548
|
+
claimedReplyMessageId: null,
|
|
549
|
+
submitting: false,
|
|
550
|
+
needsPresentation: true,
|
|
551
|
+
presentationPromise: null,
|
|
552
|
+
};
|
|
553
|
+
this.#pendingInteractions.set(key, pending);
|
|
554
|
+
this.#interactionKeys.set(pending.interactionId, key);
|
|
555
|
+
await this.#presentInteraction(pending);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#handleInteractionResolved(resolution) {
|
|
559
|
+
const interactionId = resolution?.interactionId;
|
|
560
|
+
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
|
561
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
562
|
+
if (!key) return;
|
|
563
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#presentInteraction(pending) {
|
|
567
|
+
if (!pending.needsPresentation) return Promise.resolve();
|
|
568
|
+
if (pending.presentationPromise) return pending.presentationPromise;
|
|
569
|
+
const question = pending.questions[pending.index];
|
|
570
|
+
if (!question) return Promise.resolve();
|
|
571
|
+
const presentation = this.#sendActive(
|
|
572
|
+
pending.chatId,
|
|
573
|
+
harnessQuestionText(
|
|
574
|
+
question,
|
|
575
|
+
pending.index,
|
|
576
|
+
pending.questions.length,
|
|
577
|
+
{ requiresMention: pending.requiresMention },
|
|
578
|
+
),
|
|
579
|
+
).then(() => {
|
|
580
|
+
pending.needsPresentation = false;
|
|
581
|
+
}).finally(() => {
|
|
582
|
+
if (pending.presentationPromise === presentation) {
|
|
583
|
+
pending.presentationPromise = null;
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
pending.presentationPromise = presentation;
|
|
587
|
+
return presentation;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async #discardResolvedInteractionReply(frame, messageId, chatId) {
|
|
591
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
592
|
+
await this.#state.markSeen(messageId);
|
|
593
|
+
this.#status.messagesReceived += 1;
|
|
594
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
595
|
+
await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#takePendingInteraction(key, interactionId) {
|
|
599
|
+
const pending = this.#pendingInteractions.get(key);
|
|
600
|
+
if (!pending
|
|
601
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
602
|
+
this.#pendingInteractions.delete(key);
|
|
603
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
604
|
+
return pending;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#clearPendingInteraction(key, interactionId) {
|
|
608
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async #cancelPendingInteraction(key) {
|
|
612
|
+
const pending = this.#takePendingInteraction(key);
|
|
613
|
+
if (!pending || pending.kind !== 'question') return;
|
|
614
|
+
try {
|
|
615
|
+
await pending.interaction.respond({
|
|
616
|
+
ok: false,
|
|
617
|
+
error: {
|
|
618
|
+
code: 'cancelled',
|
|
619
|
+
message: 'The Enterprise WeChat interaction ended before the user answered.',
|
|
620
|
+
details: {},
|
|
621
|
+
},
|
|
622
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
625
|
+
this.#logger.warn?.('[dsh-im:wecom] failed to cancel a pending Harness interaction');
|
|
626
|
+
}
|
|
253
627
|
}
|
|
254
628
|
}
|
|
255
629
|
}
|
|
@@ -36,6 +36,7 @@ export class WecomRuntime {
|
|
|
36
36
|
#bridge = null;
|
|
37
37
|
#starting = null;
|
|
38
38
|
#startController = null;
|
|
39
|
+
#runtimeController = null;
|
|
39
40
|
|
|
40
41
|
constructor({
|
|
41
42
|
config,
|
|
@@ -69,8 +70,10 @@ export class WecomRuntime {
|
|
|
69
70
|
async start() {
|
|
70
71
|
if (this.#status.ready && this.#client) return this.status;
|
|
71
72
|
if (this.#starting) return this.#starting;
|
|
73
|
+
this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime replaced', 'AbortError'));
|
|
72
74
|
const controller = new AbortController();
|
|
73
75
|
this.#startController = controller;
|
|
76
|
+
this.#runtimeController = controller;
|
|
74
77
|
this.#starting = this.#start(controller.signal).finally(() => {
|
|
75
78
|
if (this.#startController === controller) this.#startController = null;
|
|
76
79
|
this.#starting = null;
|
|
@@ -105,6 +108,7 @@ export class WecomRuntime {
|
|
|
105
108
|
status: this.#status,
|
|
106
109
|
logger: this.#logger,
|
|
107
110
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
111
|
+
signal,
|
|
108
112
|
});
|
|
109
113
|
|
|
110
114
|
let readyResolve;
|
|
@@ -186,6 +190,8 @@ export class WecomRuntime {
|
|
|
186
190
|
async stop() {
|
|
187
191
|
const starting = this.#starting;
|
|
188
192
|
this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
|
|
193
|
+
this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
|
|
194
|
+
this.#runtimeController = null;
|
|
189
195
|
await this.#stopActive();
|
|
190
196
|
await starting?.catch(() => undefined);
|
|
191
197
|
return this.status;
|