@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,10 +1,23 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from './workspace-command.mjs';
|
|
2
2
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
3
|
+
import {
|
|
4
|
+
harnessAnswerForQuestion,
|
|
5
|
+
harnessQuestionText,
|
|
6
|
+
validHarnessQuestion,
|
|
7
|
+
} from './harness-question.mjs';
|
|
8
|
+
|
|
9
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
3
10
|
|
|
4
11
|
function cleanText(value) {
|
|
5
12
|
return typeof value === 'string' ? value.trim() : '';
|
|
6
13
|
}
|
|
7
14
|
|
|
15
|
+
function canClaimInteractionReply(message, pending, senderId) {
|
|
16
|
+
return pending.actor === senderId
|
|
17
|
+
&& (message.kind !== 'group' || message.addressed === true)
|
|
18
|
+
&& Boolean(cleanText(message.content));
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
export function createTextBridgeStatus() {
|
|
9
22
|
return {
|
|
10
23
|
messagesReceived: 0,
|
|
@@ -25,7 +38,11 @@ export class TextHarnessBridge {
|
|
|
25
38
|
#status;
|
|
26
39
|
#logger;
|
|
27
40
|
#replyTimeoutMs;
|
|
41
|
+
#signal;
|
|
28
42
|
#queues = new Map();
|
|
43
|
+
#pendingInteractions = new Map();
|
|
44
|
+
#interactionKeys = new Map();
|
|
45
|
+
#acceptedMessageIds = new Set();
|
|
29
46
|
|
|
30
47
|
constructor({
|
|
31
48
|
descriptor,
|
|
@@ -35,6 +52,7 @@ export class TextHarnessBridge {
|
|
|
35
52
|
status = createTextBridgeStatus(),
|
|
36
53
|
logger = console,
|
|
37
54
|
replyTimeoutMs = 600_000,
|
|
55
|
+
signal,
|
|
38
56
|
}) {
|
|
39
57
|
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
|
40
58
|
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
|
@@ -46,6 +64,7 @@ export class TextHarnessBridge {
|
|
|
46
64
|
this.#status = status;
|
|
47
65
|
this.#logger = logger;
|
|
48
66
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
67
|
+
this.#signal = signal;
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
get status() {
|
|
@@ -53,14 +72,69 @@ export class TextHarnessBridge {
|
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
accept(message) {
|
|
75
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
56
76
|
const conversationId = cleanText(message?.conversationId);
|
|
57
77
|
const kind = message?.kind === 'group' ? 'group' : 'direct';
|
|
78
|
+
const normalized = { ...message, kind, conversationId };
|
|
79
|
+
const messageId = cleanText(normalized.messageId);
|
|
80
|
+
const senderId = cleanText(normalized.senderId);
|
|
81
|
+
if (!messageId || !senderId || !conversationId || normalized.senderIsBot === true
|
|
82
|
+
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
}
|
|
85
|
+
this.#acceptedMessageIds.add(messageId);
|
|
86
|
+
|
|
58
87
|
const key = `${kind}:${conversationId}`;
|
|
88
|
+
const pending = this.#pendingInteractions.get(key);
|
|
89
|
+
if (pending && pending.actor !== senderId) {
|
|
90
|
+
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
|
91
|
+
}
|
|
92
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
93
|
+
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
|
94
|
+
}
|
|
95
|
+
if (pending) {
|
|
96
|
+
if (canClaimInteractionReply(normalized, pending, senderId)) {
|
|
97
|
+
pending.claimedReplyMessageId = messageId;
|
|
98
|
+
}
|
|
99
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
100
|
+
const current = previous
|
|
101
|
+
.catch(() => undefined)
|
|
102
|
+
.then(() => this.#processInteractionReply(
|
|
103
|
+
normalized,
|
|
104
|
+
messageId,
|
|
105
|
+
senderId,
|
|
106
|
+
key,
|
|
107
|
+
pending,
|
|
108
|
+
))
|
|
109
|
+
.finally(() => {
|
|
110
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
111
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
112
|
+
pending.claimedReplyMessageId = null;
|
|
113
|
+
}
|
|
114
|
+
if (pending.queue === current) pending.queue = null;
|
|
115
|
+
});
|
|
116
|
+
pending.queue = current;
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#enqueueMessage(message, messageId, senderId, key, {
|
|
123
|
+
releaseMessageId = true,
|
|
124
|
+
alreadyRecorded = false,
|
|
125
|
+
} = {}) {
|
|
59
126
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
60
127
|
const current = previous
|
|
61
128
|
.catch(() => undefined)
|
|
62
|
-
.then(() => this.#process(
|
|
129
|
+
.then(() => this.#process(
|
|
130
|
+
message,
|
|
131
|
+
messageId,
|
|
132
|
+
senderId,
|
|
133
|
+
key,
|
|
134
|
+
{ alreadyRecorded },
|
|
135
|
+
))
|
|
63
136
|
.finally(() => {
|
|
137
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
64
138
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
65
139
|
});
|
|
66
140
|
this.#queues.set(key, current);
|
|
@@ -68,29 +142,36 @@ export class TextHarnessBridge {
|
|
|
68
142
|
}
|
|
69
143
|
|
|
70
144
|
async waitForIdle() {
|
|
71
|
-
await Promise.allSettled([
|
|
145
|
+
await Promise.allSettled([
|
|
146
|
+
...this.#queues.values(),
|
|
147
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
148
|
+
pending.queue ? [pending.queue] : []
|
|
149
|
+
)),
|
|
150
|
+
]);
|
|
72
151
|
}
|
|
73
152
|
|
|
74
|
-
async #process(message
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (message.kind === 'group' && message.addressed !== true) {
|
|
83
|
-
this.#status.messagesRejected += 1;
|
|
84
|
-
this.#status.lastRejectedAt = new Date().toISOString();
|
|
85
|
-
return;
|
|
153
|
+
async #process(message, messageId, senderId, conversationKey, {
|
|
154
|
+
alreadyRecorded = false,
|
|
155
|
+
} = {}) {
|
|
156
|
+
if (!alreadyRecorded) {
|
|
157
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
158
|
+
await this.#state.markSeen(messageId);
|
|
159
|
+
this.#status.messagesReceived += 1;
|
|
160
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
86
161
|
}
|
|
87
162
|
|
|
88
163
|
const target = message.replyTarget;
|
|
89
164
|
const text = cleanText(message.content);
|
|
165
|
+
let stream = null;
|
|
90
166
|
try {
|
|
167
|
+
this.#signal?.throwIfAborted();
|
|
168
|
+
if (message.kind === 'group' && message.addressed !== true) {
|
|
169
|
+
this.#status.messagesRejected += 1;
|
|
170
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
91
173
|
if (!text) {
|
|
92
174
|
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
|
93
|
-
await this.#state.markSeen(messageId);
|
|
94
175
|
return;
|
|
95
176
|
}
|
|
96
177
|
const command = text.toLowerCase();
|
|
@@ -102,38 +183,34 @@ export class TextHarnessBridge {
|
|
|
102
183
|
'/new 开启一个全新会话',
|
|
103
184
|
'/workspace 工作区绝对路径 切换工作区',
|
|
104
185
|
'/workspacelist 列出工作区绝对路径',
|
|
186
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
187
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
105
188
|
'/status 检查连接状态',
|
|
106
189
|
'/help 显示本帮助',
|
|
107
190
|
].join('\n'));
|
|
108
|
-
await this.#state.markSeen(messageId);
|
|
109
191
|
return;
|
|
110
192
|
}
|
|
111
193
|
if (command === '/status') {
|
|
112
|
-
await this.#harness.ensureRunning();
|
|
194
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
113
195
|
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
|
114
|
-
await this.#state.markSeen(messageId);
|
|
115
196
|
return;
|
|
116
197
|
}
|
|
117
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
198
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
|
|
118
199
|
if (workspaceCommand) {
|
|
119
200
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
120
201
|
await this.#bot.sendText(target, reply);
|
|
121
202
|
}
|
|
122
|
-
await this.#state.markSeen(messageId);
|
|
123
203
|
return;
|
|
124
204
|
}
|
|
125
|
-
const conversationKey = `${message.kind}:${message.conversationId}`;
|
|
126
205
|
if (command === '/new') {
|
|
127
206
|
await this.#state.clearSession(conversationKey);
|
|
128
207
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
|
129
|
-
await this.#state.markSeen(messageId);
|
|
130
208
|
return;
|
|
131
209
|
}
|
|
132
210
|
|
|
133
211
|
await this.#bot.sendTyping?.(target).catch((error) => {
|
|
134
212
|
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
|
135
213
|
});
|
|
136
|
-
let stream = null;
|
|
137
214
|
let streamFinished = false;
|
|
138
215
|
if (typeof this.#bot.openStream === 'function') {
|
|
139
216
|
try {
|
|
@@ -150,13 +227,23 @@ export class TextHarnessBridge {
|
|
|
150
227
|
state: this.#state,
|
|
151
228
|
key: conversationKey,
|
|
152
229
|
text,
|
|
230
|
+
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
231
|
+
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
153
232
|
askOptions: {
|
|
154
233
|
timeoutMs: this.#replyTimeoutMs,
|
|
234
|
+
signal: this.#signal,
|
|
155
235
|
onUpdate: stream ? async (update) => {
|
|
156
236
|
const progress = update.type === 'text' ? update.text
|
|
157
237
|
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
|
|
158
238
|
if (progress) await stream.update(progress);
|
|
159
239
|
} : undefined,
|
|
240
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
241
|
+
key: conversationKey,
|
|
242
|
+
actor: senderId,
|
|
243
|
+
target,
|
|
244
|
+
requiresMention: message.kind === 'group',
|
|
245
|
+
}),
|
|
246
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
160
247
|
},
|
|
161
248
|
});
|
|
162
249
|
if (stream) {
|
|
@@ -172,22 +259,349 @@ export class TextHarnessBridge {
|
|
|
172
259
|
}
|
|
173
260
|
}
|
|
174
261
|
if (!streamFinished) await this.#bot.sendText(target, answer);
|
|
175
|
-
await this.#state.markSeen(messageId);
|
|
176
262
|
this.#status.messagesReplied += 1;
|
|
177
263
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
178
264
|
this.#status.lastError = null;
|
|
179
265
|
} catch (error) {
|
|
266
|
+
stream?.cancel?.();
|
|
267
|
+
if (this.#signal?.aborted) return;
|
|
180
268
|
this.#status.lastError = error?.message ?? String(error);
|
|
181
269
|
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
|
182
270
|
try {
|
|
183
271
|
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
|
184
|
-
await this.#state.markSeen(messageId);
|
|
185
272
|
} catch (sendError) {
|
|
186
273
|
this.#logger.error?.(
|
|
187
274
|
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
|
|
188
275
|
sendError,
|
|
189
276
|
);
|
|
190
277
|
}
|
|
278
|
+
} finally {
|
|
279
|
+
await this.#cancelPendingInteraction(conversationKey);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async #processInteractionReply(message, messageId, senderId, key, expected) {
|
|
284
|
+
if (this.#signal?.aborted) return;
|
|
285
|
+
const current = this.#pendingInteractions.get(key);
|
|
286
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
287
|
+
if (!current || current !== expected || current.submitting) {
|
|
288
|
+
if (claimed && (!current || current !== expected)) {
|
|
289
|
+
return this.#discardResolvedInteractionReply(message, messageId);
|
|
290
|
+
}
|
|
291
|
+
return this.#enqueueMessage(message, messageId, senderId, key, {
|
|
292
|
+
releaseMessageId: false,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
296
|
+
await this.#state.markSeen(messageId);
|
|
297
|
+
this.#status.messagesReceived += 1;
|
|
298
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
299
|
+
|
|
300
|
+
if (message.kind === 'group' && message.addressed !== true) {
|
|
301
|
+
this.#status.messagesRejected += 1;
|
|
302
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const target = message.replyTarget;
|
|
307
|
+
const text = cleanText(message.content);
|
|
308
|
+
if (!text) {
|
|
309
|
+
try {
|
|
310
|
+
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
|
311
|
+
} catch (error) {
|
|
312
|
+
this.#logger.error?.(
|
|
313
|
+
`[dsh-im:${this.#descriptor.key}] failed to reject a non-text interaction reply:`,
|
|
314
|
+
error,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const pending = this.#pendingInteractions.get(key);
|
|
321
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
322
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
323
|
+
return this.#discardResolvedInteractionReply(message, messageId, {
|
|
324
|
+
alreadyRecorded: true,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return this.#enqueueMessage(message, messageId, senderId, key, {
|
|
328
|
+
releaseMessageId: false,
|
|
329
|
+
alreadyRecorded: true,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
pending.target = target;
|
|
333
|
+
if (pending.needsPresentation) {
|
|
334
|
+
const presentationWasInFlight = pending.presentationTask !== null;
|
|
335
|
+
try {
|
|
336
|
+
await this.#presentInteraction(pending);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`;
|
|
339
|
+
this.#logger.error?.(
|
|
340
|
+
`[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`,
|
|
341
|
+
error,
|
|
342
|
+
);
|
|
343
|
+
pending.interaction.reconnect?.();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const presented = this.#pendingInteractions.get(key);
|
|
347
|
+
if (!presented || presented !== expected || presented.submitting) {
|
|
348
|
+
if (claimed && (!presented || presented !== expected)) {
|
|
349
|
+
return this.#discardResolvedInteractionReply(message, messageId, {
|
|
350
|
+
alreadyRecorded: true,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return this.#enqueueMessage(message, messageId, senderId, key, {
|
|
354
|
+
releaseMessageId: false,
|
|
355
|
+
alreadyRecorded: true,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
// A reply can arrive after the platform accepted the question message but
|
|
359
|
+
// before its send promise settles. In that case it is already a valid
|
|
360
|
+
// answer. A message which itself retried a failed presentation is not.
|
|
361
|
+
if (!presentationWasInFlight) return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const question = pending.questions[pending.index];
|
|
365
|
+
if (!question) return;
|
|
366
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
367
|
+
pending.index += 1;
|
|
368
|
+
if (pending.index < pending.questions.length) {
|
|
369
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
370
|
+
pending.claimedReplyMessageId = null;
|
|
371
|
+
}
|
|
372
|
+
pending.needsPresentation = true;
|
|
373
|
+
try {
|
|
374
|
+
await this.#presentInteraction(pending);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`;
|
|
377
|
+
this.#logger.error?.(
|
|
378
|
+
`[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`,
|
|
379
|
+
error,
|
|
380
|
+
);
|
|
381
|
+
pending.interaction.reconnect?.();
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
pending.submitting = true;
|
|
387
|
+
try {
|
|
388
|
+
await pending.interaction.respond({
|
|
389
|
+
ok: true,
|
|
390
|
+
value: {
|
|
391
|
+
sessionId: pending.sessionId,
|
|
392
|
+
answer: { answers: pending.answers },
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
396
|
+
this.#status.lastError = null;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error?.code === 'interaction-not-pending') {
|
|
399
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
400
|
+
if (this.#signal?.aborted) return;
|
|
401
|
+
try {
|
|
402
|
+
await this.#bot.sendText(target, INTERACTION_RESOLVED_TEXT);
|
|
403
|
+
} catch (sendError) {
|
|
404
|
+
this.#logger.error?.(
|
|
405
|
+
`[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,
|
|
406
|
+
sendError,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
|
|
412
|
+
pending.submitting = false;
|
|
413
|
+
pending.answers.pop();
|
|
414
|
+
pending.index -= 1;
|
|
415
|
+
this.#status.lastError = '回答提交失败。';
|
|
416
|
+
this.#logger.error?.(
|
|
417
|
+
`[dsh-im:${this.#descriptor.key}] failed to answer a Harness interaction:`,
|
|
418
|
+
error,
|
|
419
|
+
);
|
|
420
|
+
try {
|
|
421
|
+
await this.#bot.sendText(target, '回答提交失败,请重新发送当前问题的答案。');
|
|
422
|
+
} catch (sendError) {
|
|
423
|
+
this.#logger.error?.(
|
|
424
|
+
`[dsh-im:${this.#descriptor.key}] failed to send an interaction retry notice:`,
|
|
425
|
+
sendError,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async #handleInteraction(interaction, {
|
|
432
|
+
key,
|
|
433
|
+
actor,
|
|
434
|
+
target,
|
|
435
|
+
requiresMention,
|
|
436
|
+
}) {
|
|
437
|
+
// Approval remains deliberately unanswered until #5 supplies a policy that
|
|
438
|
+
// can prove both the actor and the conversation allowed to decide it.
|
|
439
|
+
if (interaction?.kind !== 'question') return;
|
|
440
|
+
const questions = interaction?.payload?.questions;
|
|
441
|
+
const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
|
|
442
|
+
if (!cleanText(interaction?.rpcId)
|
|
443
|
+
|| !interactionId
|
|
444
|
+
|| !cleanText(interaction?.sessionId)
|
|
445
|
+
|| !Array.isArray(questions)
|
|
446
|
+
|| questions.length === 0
|
|
447
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
448
|
+
this.#logger.warn?.(
|
|
449
|
+
`[dsh-im:${this.#descriptor.key}] ignored an invalid Harness question interaction`,
|
|
450
|
+
);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (interaction.recovered === true) {
|
|
455
|
+
await this.#respondCancellation(
|
|
456
|
+
interaction,
|
|
457
|
+
`${this.#descriptor.label} safely cancelled an interaction left by an earlier client.`,
|
|
458
|
+
);
|
|
459
|
+
try {
|
|
460
|
+
await this.#bot.sendText(
|
|
461
|
+
target,
|
|
462
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
463
|
+
);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
this.#logger.error?.(
|
|
466
|
+
`[dsh-im:${this.#descriptor.key}] failed to send an interaction recovery notice:`,
|
|
467
|
+
error,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
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
|
+
this.#logger.warn?.(
|
|
482
|
+
`[dsh-im:${this.#descriptor.key}] cancelled a second pending Harness question`,
|
|
483
|
+
);
|
|
484
|
+
await this.#respondCancellation(
|
|
485
|
+
interaction,
|
|
486
|
+
`${this.#descriptor.label} is already handling another user interaction.`,
|
|
487
|
+
);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const pending = {
|
|
492
|
+
kind: 'question',
|
|
493
|
+
interactionId,
|
|
494
|
+
sessionId: interaction.sessionId,
|
|
495
|
+
interaction,
|
|
496
|
+
actor,
|
|
497
|
+
requiresMention,
|
|
498
|
+
questions,
|
|
499
|
+
answers: [],
|
|
500
|
+
index: 0,
|
|
501
|
+
target,
|
|
502
|
+
queue: null,
|
|
503
|
+
claimedReplyMessageId: null,
|
|
504
|
+
submitting: false,
|
|
505
|
+
needsPresentation: true,
|
|
506
|
+
presentationTask: null,
|
|
507
|
+
};
|
|
508
|
+
this.#pendingInteractions.set(key, pending);
|
|
509
|
+
this.#interactionKeys.set(interactionId, key);
|
|
510
|
+
await this.#presentInteraction(pending);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#handleInteractionResolved(resolution) {
|
|
514
|
+
const interactionId = cleanText(resolution?.interactionId);
|
|
515
|
+
if (resolution?.kind !== 'question' || !interactionId) return;
|
|
516
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
517
|
+
if (!key) return;
|
|
518
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#presentInteraction(pending) {
|
|
522
|
+
if (pending.presentationTask) return pending.presentationTask;
|
|
523
|
+
const question = pending.questions[pending.index];
|
|
524
|
+
if (!question) return Promise.resolve();
|
|
525
|
+
const task = (async () => {
|
|
526
|
+
await this.#bot.sendText(
|
|
527
|
+
pending.target,
|
|
528
|
+
harnessQuestionText(
|
|
529
|
+
question,
|
|
530
|
+
pending.index,
|
|
531
|
+
pending.questions.length,
|
|
532
|
+
{ requiresMention: pending.requiresMention },
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
pending.needsPresentation = false;
|
|
536
|
+
})();
|
|
537
|
+
pending.presentationTask = task;
|
|
538
|
+
task.then(
|
|
539
|
+
() => {
|
|
540
|
+
if (pending.presentationTask === task) pending.presentationTask = null;
|
|
541
|
+
},
|
|
542
|
+
() => {
|
|
543
|
+
if (pending.presentationTask === task) pending.presentationTask = null;
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
return task;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async #discardResolvedInteractionReply(message, messageId, {
|
|
550
|
+
alreadyRecorded = false,
|
|
551
|
+
} = {}) {
|
|
552
|
+
if (!alreadyRecorded) {
|
|
553
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
554
|
+
await this.#state.markSeen(messageId);
|
|
555
|
+
this.#status.messagesReceived += 1;
|
|
556
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.#logger.error?.(
|
|
562
|
+
`[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,
|
|
563
|
+
error,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#takePendingInteraction(key, interactionId) {
|
|
569
|
+
const pending = this.#pendingInteractions.get(key);
|
|
570
|
+
if (!pending
|
|
571
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
572
|
+
this.#pendingInteractions.delete(key);
|
|
573
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
574
|
+
return pending;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#clearPendingInteraction(key, interactionId) {
|
|
578
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async #respondCancellation(interaction, message) {
|
|
582
|
+
try {
|
|
583
|
+
await interaction.respond({
|
|
584
|
+
ok: false,
|
|
585
|
+
error: { code: 'cancelled', message, details: {} },
|
|
586
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error?.code !== 'interaction-not-pending') throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async #cancelPendingInteraction(key) {
|
|
593
|
+
const pending = this.#takePendingInteraction(key);
|
|
594
|
+
if (!pending || pending.kind !== 'question') return;
|
|
595
|
+
try {
|
|
596
|
+
await this.#respondCancellation(
|
|
597
|
+
pending.interaction,
|
|
598
|
+
`The ${this.#descriptor.label} interaction ended before the user answered.`,
|
|
599
|
+
);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
this.#logger.warn?.(
|
|
602
|
+
`[dsh-im:${this.#descriptor.key}] failed to cancel a pending Harness interaction:`,
|
|
603
|
+
error,
|
|
604
|
+
);
|
|
191
605
|
}
|
|
192
606
|
}
|
|
193
607
|
}
|