@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,6 +1,13 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
2
|
+
import {
|
|
3
|
+
harnessAnswerForQuestion,
|
|
4
|
+
harnessQuestionText,
|
|
5
|
+
validHarnessQuestion,
|
|
6
|
+
} from '../shared/harness-question.mjs';
|
|
2
7
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
3
8
|
|
|
9
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
10
|
+
|
|
4
11
|
const HELP_TEXT = [
|
|
5
12
|
'QQ 机器人已连接 DeepSeek Harness。',
|
|
6
13
|
'',
|
|
@@ -8,6 +15,8 @@ const HELP_TEXT = [
|
|
|
8
15
|
'/new 开启一个全新会话',
|
|
9
16
|
'/workspace 工作区绝对路径 切换工作区',
|
|
10
17
|
'/workspacelist 列出工作区绝对路径',
|
|
18
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
19
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
11
20
|
'/status 检查连接状态',
|
|
12
21
|
'/help 显示本帮助',
|
|
13
22
|
].join('\n');
|
|
@@ -20,6 +29,17 @@ function safeText(message) {
|
|
|
20
29
|
return typeof message?.content === 'string' ? message.content.trim() : '';
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
function nonEmptyString(value) {
|
|
33
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function canClaimInteractionReply(message, pending) {
|
|
37
|
+
return pending.questions[pending.index]
|
|
38
|
+
&& nonEmptyString(message?.senderId) === pending.actor
|
|
39
|
+
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
|
40
|
+
&& nonEmptyString(safeText(message));
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
export function createQqBridgeStatus() {
|
|
24
44
|
return {
|
|
25
45
|
messagesReceived: 0,
|
|
@@ -40,7 +60,11 @@ export class QqHarnessBridge {
|
|
|
40
60
|
#status;
|
|
41
61
|
#logger;
|
|
42
62
|
#replyTimeoutMs;
|
|
63
|
+
#signal;
|
|
43
64
|
#queues = new Map();
|
|
65
|
+
#pendingInteractions = new Map();
|
|
66
|
+
#interactionKeys = new Map();
|
|
67
|
+
#acceptedMessageIds = new Set();
|
|
44
68
|
|
|
45
69
|
constructor({
|
|
46
70
|
bot,
|
|
@@ -50,6 +74,7 @@ export class QqHarnessBridge {
|
|
|
50
74
|
status = createQqBridgeStatus(),
|
|
51
75
|
logger = console,
|
|
52
76
|
replyTimeoutMs = 600_000,
|
|
77
|
+
signal,
|
|
53
78
|
}) {
|
|
54
79
|
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
|
|
55
80
|
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
|
|
@@ -61,6 +86,7 @@ export class QqHarnessBridge {
|
|
|
61
86
|
this.#status = status;
|
|
62
87
|
this.#logger = logger;
|
|
63
88
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
89
|
+
this.#signal = signal;
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
get status() {
|
|
@@ -68,12 +94,52 @@ export class QqHarnessBridge {
|
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
accept(message) {
|
|
97
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
98
|
+
const messageId = nonEmptyString(message?.messageId);
|
|
99
|
+
const sender = nonEmptyString(message?.senderId);
|
|
100
|
+
if (!messageId || !sender || message?.senderIsBot === true
|
|
101
|
+
|| !['c2c', 'group'].includes(message?.kind)
|
|
102
|
+
|| this.#state.hasSeen(messageId)
|
|
103
|
+
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
71
104
|
const key = conversationKey(message);
|
|
105
|
+
this.#acceptedMessageIds.add(messageId);
|
|
106
|
+
const pending = this.#pendingInteractions.get(key);
|
|
107
|
+
if (pending && sender !== pending.actor) {
|
|
108
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
109
|
+
}
|
|
110
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
111
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
112
|
+
}
|
|
113
|
+
if (pending) {
|
|
114
|
+
if (canClaimInteractionReply(message, pending)) {
|
|
115
|
+
pending.claimedReplyMessageId = messageId;
|
|
116
|
+
}
|
|
117
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
118
|
+
const current = previous
|
|
119
|
+
.catch(() => undefined)
|
|
120
|
+
.then(() => this.#processInteractionReply(message, messageId, key, pending))
|
|
121
|
+
.catch((error) => this.#handleInteractionFailure(message, messageId, error))
|
|
122
|
+
.finally(() => {
|
|
123
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
124
|
+
if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
|
|
125
|
+
if (pending.queue === current) pending.queue = null;
|
|
126
|
+
});
|
|
127
|
+
pending.queue = current;
|
|
128
|
+
return current;
|
|
129
|
+
}
|
|
130
|
+
return this.#enqueueMessage(message, messageId, key);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#enqueueMessage(message, messageId, key, {
|
|
134
|
+
releaseMessageId = true,
|
|
135
|
+
alreadyRecorded = false,
|
|
136
|
+
} = {}) {
|
|
72
137
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
73
138
|
const current = previous
|
|
74
139
|
.catch(() => undefined)
|
|
75
|
-
.then(() => this.#process(message))
|
|
140
|
+
.then(() => this.#process(message, key, { alreadyRecorded }))
|
|
76
141
|
.finally(() => {
|
|
142
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
77
143
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
78
144
|
});
|
|
79
145
|
this.#queues.set(key, current);
|
|
@@ -81,17 +147,25 @@ export class QqHarnessBridge {
|
|
|
81
147
|
}
|
|
82
148
|
|
|
83
149
|
async waitForIdle() {
|
|
84
|
-
await Promise.allSettled([
|
|
150
|
+
await Promise.allSettled([
|
|
151
|
+
...this.#queues.values(),
|
|
152
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
153
|
+
pending.queue ? [pending.queue] : []
|
|
154
|
+
)),
|
|
155
|
+
]);
|
|
85
156
|
}
|
|
86
157
|
|
|
87
|
-
async #process(message) {
|
|
88
|
-
|
|
89
|
-
const
|
|
158
|
+
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
159
|
+
if (this.#signal?.aborted) return;
|
|
160
|
+
const messageId = nonEmptyString(message?.messageId);
|
|
161
|
+
const sender = nonEmptyString(message?.senderId);
|
|
90
162
|
if (!messageId || !sender || message.senderIsBot === true) return;
|
|
91
|
-
if (!['c2c', 'group'].includes(message.kind)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
163
|
+
if (!['c2c', 'group'].includes(message.kind)) return;
|
|
164
|
+
if (!alreadyRecorded) {
|
|
165
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
166
|
+
this.#status.messagesReceived += 1;
|
|
167
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
168
|
+
}
|
|
95
169
|
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
|
|
96
170
|
this.#status.messagesRejected += 1;
|
|
97
171
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
@@ -114,19 +188,18 @@ export class QqHarnessBridge {
|
|
|
114
188
|
return;
|
|
115
189
|
}
|
|
116
190
|
if (command === '/status') {
|
|
117
|
-
await this.#harness.ensureRunning();
|
|
191
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
118
192
|
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
|
119
193
|
await this.#state.markSeen(messageId);
|
|
120
194
|
return;
|
|
121
195
|
}
|
|
122
|
-
const key = conversationKey(message);
|
|
123
196
|
if (command === '/new') {
|
|
124
197
|
await this.#state.clearSession(key);
|
|
125
198
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
126
199
|
await this.#state.markSeen(messageId);
|
|
127
200
|
return;
|
|
128
201
|
}
|
|
129
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
202
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
130
203
|
if (workspaceCommand) {
|
|
131
204
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
132
205
|
await this.#bot.sendText(target, reply);
|
|
@@ -144,23 +217,38 @@ export class QqHarnessBridge {
|
|
|
144
217
|
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
|
|
145
218
|
}
|
|
146
219
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
220
|
+
let answer;
|
|
221
|
+
try {
|
|
222
|
+
({ answer } = await askInWorkspaceSession({
|
|
223
|
+
harness: this.#harness,
|
|
224
|
+
state: this.#state,
|
|
225
|
+
key,
|
|
226
|
+
text,
|
|
227
|
+
createOptions: { signal: this.#signal },
|
|
228
|
+
existsOptions: { signal: this.#signal },
|
|
229
|
+
askOptions: {
|
|
230
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
231
|
+
signal: this.#signal,
|
|
232
|
+
onUpdate: stream ? async (update) => {
|
|
233
|
+
const progress = update.type === 'text'
|
|
234
|
+
? update.text
|
|
235
|
+
: update.type === 'tool'
|
|
236
|
+
? `正在使用${update.name}…`
|
|
237
|
+
: update.text;
|
|
238
|
+
if (progress) await stream.update(progress);
|
|
239
|
+
} : undefined,
|
|
240
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
241
|
+
key,
|
|
242
|
+
actor: sender,
|
|
243
|
+
target,
|
|
244
|
+
requiresMention: message.kind === 'group',
|
|
245
|
+
}),
|
|
246
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
247
|
+
},
|
|
248
|
+
}));
|
|
249
|
+
} finally {
|
|
250
|
+
await this.#cancelPendingInteraction(key);
|
|
251
|
+
}
|
|
164
252
|
if (stream) {
|
|
165
253
|
try {
|
|
166
254
|
await stream.update(answer);
|
|
@@ -177,6 +265,7 @@ export class QqHarnessBridge {
|
|
|
177
265
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
178
266
|
this.#status.lastError = null;
|
|
179
267
|
} catch (error) {
|
|
268
|
+
if (this.#signal?.aborted) return;
|
|
180
269
|
this.#status.lastError = error?.message ?? String(error);
|
|
181
270
|
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
|
182
271
|
try {
|
|
@@ -187,4 +276,269 @@ export class QqHarnessBridge {
|
|
|
187
276
|
}
|
|
188
277
|
}
|
|
189
278
|
}
|
|
279
|
+
|
|
280
|
+
async #processInteractionReply(message, messageId, key, expected) {
|
|
281
|
+
this.#signal?.throwIfAborted();
|
|
282
|
+
const current = this.#pendingInteractions.get(key);
|
|
283
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
284
|
+
if (!current || current !== expected || current.submitting) {
|
|
285
|
+
if (claimed && (!current || current !== expected)) {
|
|
286
|
+
return this.#discardResolvedInteractionReply(message, messageId);
|
|
287
|
+
}
|
|
288
|
+
return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
|
|
289
|
+
}
|
|
290
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
291
|
+
await this.#state.markSeen(messageId);
|
|
292
|
+
this.#status.messagesReceived += 1;
|
|
293
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
294
|
+
|
|
295
|
+
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
|
296
|
+
const text = nonEmptyString(safeText(message));
|
|
297
|
+
if (!text) {
|
|
298
|
+
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const pending = this.#pendingInteractions.get(key);
|
|
303
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
304
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
305
|
+
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
309
|
+
releaseMessageId: false,
|
|
310
|
+
alreadyRecorded: true,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
pending.target = message.replyTarget;
|
|
314
|
+
if (pending.needsPresentation) {
|
|
315
|
+
try {
|
|
316
|
+
await this.#presentInteraction(pending);
|
|
317
|
+
} catch {
|
|
318
|
+
this.#status.lastError = 'QQ 交互问题发送失败。';
|
|
319
|
+
this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
|
|
320
|
+
pending.interaction.reconnect?.();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const presentedPending = this.#pendingInteractions.get(key);
|
|
324
|
+
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
|
|
325
|
+
if (claimed && (!presentedPending || presentedPending !== expected)) {
|
|
326
|
+
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT)
|
|
327
|
+
.catch(() => undefined);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
return this.#enqueueMessage(message, messageId, key, {
|
|
331
|
+
releaseMessageId: false,
|
|
332
|
+
alreadyRecorded: true,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const question = pending.questions[pending.index];
|
|
338
|
+
if (!question) return;
|
|
339
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
340
|
+
pending.index += 1;
|
|
341
|
+
if (pending.index < pending.questions.length) {
|
|
342
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
343
|
+
pending.claimedReplyMessageId = null;
|
|
344
|
+
}
|
|
345
|
+
pending.needsPresentation = true;
|
|
346
|
+
try {
|
|
347
|
+
await this.#presentInteraction(pending);
|
|
348
|
+
} catch {
|
|
349
|
+
this.#status.lastError = 'QQ 交互问题发送失败。';
|
|
350
|
+
this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
|
|
351
|
+
pending.interaction.reconnect?.();
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
pending.submitting = true;
|
|
357
|
+
try {
|
|
358
|
+
await pending.interaction.respond({
|
|
359
|
+
ok: true,
|
|
360
|
+
value: {
|
|
361
|
+
sessionId: pending.sessionId,
|
|
362
|
+
answer: { answers: pending.answers },
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
366
|
+
this.#status.lastError = null;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (this.#signal?.aborted) return;
|
|
369
|
+
if (error?.code === 'interaction-not-pending') {
|
|
370
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
371
|
+
await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
375
|
+
pending.submitting = false;
|
|
376
|
+
pending.answers.pop();
|
|
377
|
+
pending.index -= 1;
|
|
378
|
+
this.#status.lastError = '回答提交失败。';
|
|
379
|
+
this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
|
|
380
|
+
await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。')
|
|
381
|
+
.catch(() => undefined);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async #handleInteraction(interaction, {
|
|
386
|
+
key,
|
|
387
|
+
actor,
|
|
388
|
+
target,
|
|
389
|
+
requiresMention,
|
|
390
|
+
}) {
|
|
391
|
+
// Approval remains fail-closed until #5 adds an authenticated policy.
|
|
392
|
+
if (interaction?.kind !== 'question') return;
|
|
393
|
+
const questions = interaction?.payload?.questions;
|
|
394
|
+
const interactionId = typeof interaction?.interactionId === 'string'
|
|
395
|
+
? interaction.interactionId
|
|
396
|
+
: interaction?.rpcId;
|
|
397
|
+
if (typeof interaction?.rpcId !== 'string'
|
|
398
|
+
|| typeof interactionId !== 'string'
|
|
399
|
+
|| typeof interaction.sessionId !== 'string'
|
|
400
|
+
|| !Array.isArray(questions)
|
|
401
|
+
|| questions.length === 0
|
|
402
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
403
|
+
this.#logger.warn?.('[dsh-im:qq] ignored an invalid Harness question interaction');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (interaction.recovered === true) {
|
|
408
|
+
await interaction.respond({
|
|
409
|
+
ok: false,
|
|
410
|
+
error: {
|
|
411
|
+
code: 'cancelled',
|
|
412
|
+
message: 'QQ safely cancelled an interaction left by an earlier client.',
|
|
413
|
+
details: {},
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
await this.#bot.sendText(
|
|
417
|
+
target,
|
|
418
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
419
|
+
).catch(() => undefined);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const existing = this.#pendingInteractions.get(key);
|
|
424
|
+
if (existing?.interactionId === interactionId) {
|
|
425
|
+
existing.interaction = interaction;
|
|
426
|
+
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (this.#interactionKeys.has(interactionId)) return;
|
|
430
|
+
if (existing) {
|
|
431
|
+
await interaction.respond({
|
|
432
|
+
ok: false,
|
|
433
|
+
error: {
|
|
434
|
+
code: 'cancelled',
|
|
435
|
+
message: 'QQ is already handling another user interaction.',
|
|
436
|
+
details: {},
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const pending = {
|
|
443
|
+
kind: 'question',
|
|
444
|
+
interactionId,
|
|
445
|
+
sessionId: interaction.sessionId,
|
|
446
|
+
interaction,
|
|
447
|
+
actor,
|
|
448
|
+
requiresMention,
|
|
449
|
+
questions,
|
|
450
|
+
answers: [],
|
|
451
|
+
index: 0,
|
|
452
|
+
target,
|
|
453
|
+
queue: null,
|
|
454
|
+
claimedReplyMessageId: null,
|
|
455
|
+
presentationPromise: null,
|
|
456
|
+
submitting: false,
|
|
457
|
+
needsPresentation: true,
|
|
458
|
+
};
|
|
459
|
+
this.#pendingInteractions.set(key, pending);
|
|
460
|
+
this.#interactionKeys.set(interactionId, key);
|
|
461
|
+
await this.#presentInteraction(pending);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#handleInteractionResolved(resolution) {
|
|
465
|
+
const interactionId = resolution?.interactionId;
|
|
466
|
+
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
|
467
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
468
|
+
if (!key) return;
|
|
469
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#presentInteraction(pending) {
|
|
473
|
+
if (!pending.needsPresentation) return Promise.resolve();
|
|
474
|
+
if (pending.presentationPromise) return pending.presentationPromise;
|
|
475
|
+
const question = pending.questions[pending.index];
|
|
476
|
+
if (!question) return Promise.resolve();
|
|
477
|
+
const presentation = this.#bot.sendText(
|
|
478
|
+
pending.target,
|
|
479
|
+
harnessQuestionText(
|
|
480
|
+
question,
|
|
481
|
+
pending.index,
|
|
482
|
+
pending.questions.length,
|
|
483
|
+
{ requiresMention: pending.requiresMention },
|
|
484
|
+
),
|
|
485
|
+
).then(() => {
|
|
486
|
+
pending.needsPresentation = false;
|
|
487
|
+
}).finally(() => {
|
|
488
|
+
if (pending.presentationPromise === presentation) pending.presentationPromise = null;
|
|
489
|
+
});
|
|
490
|
+
pending.presentationPromise = presentation;
|
|
491
|
+
return presentation;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async #discardResolvedInteractionReply(message, messageId) {
|
|
495
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
496
|
+
await this.#state.markSeen(messageId);
|
|
497
|
+
this.#status.messagesReceived += 1;
|
|
498
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
499
|
+
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#takePendingInteraction(key, interactionId) {
|
|
503
|
+
const pending = this.#pendingInteractions.get(key);
|
|
504
|
+
if (!pending
|
|
505
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
506
|
+
this.#pendingInteractions.delete(key);
|
|
507
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
508
|
+
return pending;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#clearPendingInteraction(key, interactionId) {
|
|
512
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async #cancelPendingInteraction(key) {
|
|
516
|
+
const pending = this.#takePendingInteraction(key);
|
|
517
|
+
if (!pending || pending.kind !== 'question') return;
|
|
518
|
+
try {
|
|
519
|
+
await pending.interaction.respond({
|
|
520
|
+
ok: false,
|
|
521
|
+
error: {
|
|
522
|
+
code: 'cancelled',
|
|
523
|
+
message: 'The QQ interaction ended before the user answered.',
|
|
524
|
+
details: {},
|
|
525
|
+
},
|
|
526
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
529
|
+
this.#logger.warn?.('[dsh-im:qq] failed to cancel a pending Harness interaction');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async #handleInteractionFailure(message, messageId, error) {
|
|
535
|
+
if (this.#signal?.aborted) return;
|
|
536
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
537
|
+
this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
|
|
538
|
+
if (!this.#state.hasSeen(messageId)) {
|
|
539
|
+
await this.#state.markSeen(messageId).catch(() => undefined);
|
|
540
|
+
}
|
|
541
|
+
await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
|
|
542
|
+
.catch(() => undefined);
|
|
543
|
+
}
|
|
190
544
|
}
|
|
@@ -101,6 +101,8 @@ export class QqRuntime {
|
|
|
101
101
|
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
|
|
102
102
|
throw new TypeError('QQ bot factory returned an invalid client');
|
|
103
103
|
}
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
this.#abortController = controller;
|
|
104
106
|
this.#bot = bot;
|
|
105
107
|
this.#bridge = new QqHarnessBridge({
|
|
106
108
|
bot,
|
|
@@ -110,6 +112,7 @@ export class QqRuntime {
|
|
|
110
112
|
status: this.#status,
|
|
111
113
|
logger: this.#logger,
|
|
112
114
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
115
|
+
signal: controller.signal,
|
|
113
116
|
});
|
|
114
117
|
bot.use?.(this.#typingMiddleware({
|
|
115
118
|
keepAlive: true,
|
|
@@ -117,8 +120,6 @@ export class QqRuntime {
|
|
|
117
120
|
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
|
118
121
|
}));
|
|
119
122
|
|
|
120
|
-
const controller = new AbortController();
|
|
121
|
-
this.#abortController = controller;
|
|
122
123
|
let readyResolve;
|
|
123
124
|
let readyReject;
|
|
124
125
|
const ready = new Promise((resolve, reject) => {
|
|
@@ -141,7 +142,17 @@ export class QqRuntime {
|
|
|
141
142
|
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
|
|
142
143
|
}
|
|
143
144
|
};
|
|
144
|
-
const onMessage = (_ctx, message) =>
|
|
145
|
+
const onMessage = (_ctx, message) => {
|
|
146
|
+
const task = this.#bridge?.accept(message);
|
|
147
|
+
if (!task) return;
|
|
148
|
+
void task.catch((error) => {
|
|
149
|
+
if (controller.signal.aborted) return;
|
|
150
|
+
this.#logger.error?.(
|
|
151
|
+
`[dsh-im:qq] bot ${this.#config.botId} message handling failed:`,
|
|
152
|
+
error,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
};
|
|
145
156
|
bot.on('ready', onReady);
|
|
146
157
|
bot.on('resumed', onReady);
|
|
147
158
|
bot.on('error', onError);
|