@xmanrui/dsh-im 0.7.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.
Files changed (29) hide show
  1. package/lib/index.js +118 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +360 -8
  4. package/src/channels/dingtalk/harness-client.mjs +16 -366
  5. package/src/channels/discord/discord-api.mjs +1 -1
  6. package/src/channels/discord/discord-runtime.mjs +11 -1
  7. package/src/channels/discord/harness-client.mjs +10 -2
  8. package/src/channels/feishu/bridge.mjs +522 -50
  9. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  10. package/src/channels/feishu/harness-client.mjs +16 -335
  11. package/src/channels/qq/harness-client.mjs +10 -2
  12. package/src/channels/qq/qq-bridge.mjs +380 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-client.mjs +825 -0
  15. package/src/channels/shared/harness-question.mjs +85 -0
  16. package/src/channels/shared/text-harness-bridge.mjs +436 -24
  17. package/src/channels/slack/harness-client.mjs +10 -2
  18. package/src/channels/slack/slack-runtime.mjs +11 -1
  19. package/src/channels/telegram/harness-client.mjs +10 -2
  20. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  21. package/src/channels/wecom/harness-client.mjs +10 -2
  22. package/src/channels/wecom/wecom-bridge.mjs +386 -14
  23. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  24. package/src/channels/weixin/harness-client.mjs +16 -326
  25. package/src/channels/weixin/weixin-api.mjs +1 -1
  26. package/src/channels/weixin/weixin-bridge.mjs +402 -22
  27. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  28. package/src/channels/whatsapp/harness-client.mjs +10 -2
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -0,0 +1,85 @@
1
+ function nonEmptyString(value) {
2
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
3
+ }
4
+
5
+ export function validHarnessQuestion(question) {
6
+ return question && typeof question.id === 'string' && typeof question.question === 'string'
7
+ && (question.header === undefined || typeof question.header === 'string')
8
+ && (question.detail === undefined || typeof question.detail === 'string')
9
+ && (question.multiSelect === undefined || typeof question.multiSelect === 'boolean')
10
+ && (question.options === undefined || (Array.isArray(question.options)
11
+ && question.options.every((option) => (
12
+ option && typeof option.label === 'string'
13
+ && (option.description === undefined || typeof option.description === 'string')
14
+ ))));
15
+ }
16
+
17
+ export function harnessQuestionText(question, index, total, { requiresMention = false } = {}) {
18
+ const lines = [];
19
+ const progress = total > 1 ? `(${index + 1}/${total})` : '';
20
+ lines.push(`DeepSeek Harness 需要你补充信息${progress}:`);
21
+ if (nonEmptyString(question.header)) lines.push('', question.header.trim());
22
+ lines.push('', nonEmptyString(question.question) ?? '请输入你的回答。');
23
+ if (nonEmptyString(question.detail)) lines.push('', question.detail.trim());
24
+
25
+ const options = Array.isArray(question.options) ? question.options : [];
26
+ if (options.length > 0) {
27
+ lines.push('');
28
+ options.forEach((option, optionIndex) => {
29
+ const label = typeof option?.label === 'string' ? option.label : '';
30
+ const description = nonEmptyString(option?.description);
31
+ lines.push(`${optionIndex + 1}. ${label}${description ? ` — ${description}` : ''}`);
32
+ });
33
+ lines.push('', question.multiSelect === true
34
+ ? '请回复选项序号或文字;多选用逗号分隔,也可补充其他内容。'
35
+ : '请回复一个选项序号或文字,也可直接输入其他答案。');
36
+ } else {
37
+ lines.push('', '请直接回复你的答案。');
38
+ }
39
+ if (requiresMention) lines.push('', '群聊中请 @机器人 后发送答案。');
40
+ return lines.join('\n');
41
+ }
42
+
43
+ function optionLabel(token, options) {
44
+ const normalized = token.trim();
45
+ if (!normalized) return null;
46
+ if (/^\d+$/.test(normalized)) {
47
+ const option = options[Number(normalized) - 1];
48
+ return typeof option?.label === 'string' ? option.label : null;
49
+ }
50
+ const exact = options.find((option) => option?.label === normalized);
51
+ return typeof exact?.label === 'string' ? exact.label : null;
52
+ }
53
+
54
+ export function harnessAnswerForQuestion(question, text) {
55
+ const options = Array.isArray(question.options) ? question.options : [];
56
+ if (options.length === 0) {
57
+ return { id: question.id, selected: [], custom: text };
58
+ }
59
+
60
+ const wholeLabel = optionLabel(text, options);
61
+ if (question.multiSelect !== true) {
62
+ return wholeLabel
63
+ ? { id: question.id, selected: [wholeLabel] }
64
+ : { id: question.id, selected: [], custom: text };
65
+ }
66
+ if (wholeLabel) return { id: question.id, selected: [wholeLabel] };
67
+
68
+ const selected = [];
69
+ const custom = [];
70
+ for (const token of text.split(/[,,、;;\n]+/)) {
71
+ const value = token.trim();
72
+ if (!value) continue;
73
+ const label = optionLabel(value, options);
74
+ if (label) {
75
+ if (!selected.includes(label)) selected.push(label);
76
+ } else {
77
+ custom.push(value);
78
+ }
79
+ }
80
+ return {
81
+ id: question.id,
82
+ selected,
83
+ ...(custom.length > 0 ? { custom: custom.join('、') } : {}),
84
+ };
85
+ }
@@ -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({ ...message, kind, conversationId }))
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([...this.#queues.values()]);
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
- const messageId = cleanText(message.messageId);
76
- const senderId = cleanText(message.senderId);
77
- if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return;
78
- if (this.#state.hasSeen(messageId)) return;
79
-
80
- this.#status.messagesReceived += 1;
81
- this.#status.lastMessageAt = new Date().toISOString();
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();
@@ -107,35 +188,29 @@ export class TextHarnessBridge {
107
188
  '/status 检查连接状态',
108
189
  '/help 显示本帮助',
109
190
  ].join('\n'));
110
- await this.#state.markSeen(messageId);
111
191
  return;
112
192
  }
113
193
  if (command === '/status') {
114
- await this.#harness.ensureRunning();
194
+ await this.#harness.ensureRunning({ signal: this.#signal });
115
195
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
116
- await this.#state.markSeen(messageId);
117
196
  return;
118
197
  }
119
- const conversationKey = `${message.kind}:${message.conversationId}`;
120
198
  const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
121
199
  if (workspaceCommand) {
122
200
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
123
201
  await this.#bot.sendText(target, reply);
124
202
  }
125
- await this.#state.markSeen(messageId);
126
203
  return;
127
204
  }
128
205
  if (command === '/new') {
129
206
  await this.#state.clearSession(conversationKey);
130
207
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
131
- await this.#state.markSeen(messageId);
132
208
  return;
133
209
  }
134
210
 
135
211
  await this.#bot.sendTyping?.(target).catch((error) => {
136
212
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
137
213
  });
138
- let stream = null;
139
214
  let streamFinished = false;
140
215
  if (typeof this.#bot.openStream === 'function') {
141
216
  try {
@@ -152,13 +227,23 @@ export class TextHarnessBridge {
152
227
  state: this.#state,
153
228
  key: conversationKey,
154
229
  text,
230
+ createOptions: this.#signal ? { signal: this.#signal } : undefined,
231
+ existsOptions: this.#signal ? { signal: this.#signal } : undefined,
155
232
  askOptions: {
156
233
  timeoutMs: this.#replyTimeoutMs,
234
+ signal: this.#signal,
157
235
  onUpdate: stream ? async (update) => {
158
236
  const progress = update.type === 'text' ? update.text
159
237
  : update.type === 'tool' ? `正在使用${update.name}…` : update.text;
160
238
  if (progress) await stream.update(progress);
161
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),
162
247
  },
163
248
  });
164
249
  if (stream) {
@@ -174,22 +259,349 @@ export class TextHarnessBridge {
174
259
  }
175
260
  }
176
261
  if (!streamFinished) await this.#bot.sendText(target, answer);
177
- await this.#state.markSeen(messageId);
178
262
  this.#status.messagesReplied += 1;
179
263
  this.#status.lastReplyAt = new Date().toISOString();
180
264
  this.#status.lastError = null;
181
265
  } catch (error) {
266
+ stream?.cancel?.();
267
+ if (this.#signal?.aborted) return;
182
268
  this.#status.lastError = error?.message ?? String(error);
183
269
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
184
270
  try {
185
271
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
186
- await this.#state.markSeen(messageId);
187
272
  } catch (sendError) {
188
273
  this.#logger.error?.(
189
274
  `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
190
275
  sendError,
191
276
  );
192
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
+ );
193
605
  }
194
606
  }
195
607
  }
@@ -1,3 +1,11 @@
1
- import { HarnessClient } from '../weixin/harness-client.mjs';
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
2
 
3
- export class SlackHarnessClient extends HarnessClient {}
3
+ export class SlackHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'slack',
8
+ logPrefix: 'dsh-slack',
9
+ });
10
+ }
11
+ }