@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.
Files changed (42) hide show
  1. package/README.md +22 -4
  2. package/lib/client.js +649 -357
  3. package/lib/index.js +120 -111
  4. package/package.json +1 -1
  5. package/plugin-src/client/build.mjs +1 -1
  6. package/plugin-src/client/i18n.js +14 -0
  7. package/plugin-src/client/index.js +11 -3
  8. package/plugin-src/client/styles.js +49 -8
  9. package/plugin-src/client/workspace-directory-picker.js +230 -0
  10. package/plugin-src/client/workspace-editor.js +38 -65
  11. package/scripts/verify-package.mjs +5 -0
  12. package/src/channels/dingtalk/dingtalk-bridge.mjs +363 -9
  13. package/src/channels/dingtalk/harness-client.mjs +16 -310
  14. package/src/channels/discord/discord-api.mjs +1 -1
  15. package/src/channels/discord/discord-runtime.mjs +11 -1
  16. package/src/channels/discord/harness-client.mjs +10 -2
  17. package/src/channels/feishu/bridge.mjs +525 -51
  18. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  19. package/src/channels/feishu/harness-client.mjs +16 -279
  20. package/src/channels/qq/harness-client.mjs +10 -2
  21. package/src/channels/qq/qq-bridge.mjs +383 -29
  22. package/src/channels/qq/qq-runtime.mjs +14 -3
  23. package/src/channels/shared/bot-workspace-store.mjs +185 -4
  24. package/src/channels/shared/harness-client.mjs +825 -0
  25. package/src/channels/shared/harness-question.mjs +85 -0
  26. package/src/channels/shared/harness-session-binding.mjs +110 -0
  27. package/src/channels/shared/text-harness-bridge.mjs +439 -25
  28. package/src/channels/shared/workspace-command.mjs +212 -16
  29. package/src/channels/shared/workspace-session.mjs +22 -9
  30. package/src/channels/slack/harness-client.mjs +10 -2
  31. package/src/channels/slack/slack-runtime.mjs +11 -1
  32. package/src/channels/telegram/harness-client.mjs +10 -2
  33. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  34. package/src/channels/wecom/harness-client.mjs +10 -2
  35. package/src/channels/wecom/wecom-bridge.mjs +389 -15
  36. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  37. package/src/channels/weixin/harness-client.mjs +16 -270
  38. package/src/channels/weixin/weixin-api.mjs +1 -1
  39. package/src/channels/weixin/weixin-bridge.mjs +406 -24
  40. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  41. package/src/channels/whatsapp/harness-client.mjs +10 -2
  42. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -3,11 +3,17 @@ import {
3
3
  splitDingtalkText,
4
4
  } from './dingtalk-api.mjs';
5
5
  import { createDingTalkCardStream } from './dingtalk-card-stream.mjs';
6
+ import {
7
+ harnessAnswerForQuestion,
8
+ harnessQuestionText,
9
+ validHarnessQuestion,
10
+ } from '../shared/harness-question.mjs';
6
11
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
7
12
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
8
13
 
9
14
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
10
15
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
16
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
11
17
 
12
18
  const HELP_TEXT = [
13
19
  '钉钉机器人已连接 DeepSeek Harness。',
@@ -16,6 +22,8 @@ const HELP_TEXT = [
16
22
  '/new 开启一个全新会话',
17
23
  '/workspace 工作区绝对路径 切换工作区',
18
24
  '/workspacelist 列出工作区绝对路径',
25
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
26
+ '/session Session ID 将当前聊天绑定到指定会话',
19
27
  '/status 检查连接状态',
20
28
  '/help 显示本帮助',
21
29
  ].join('\n');
@@ -53,6 +61,19 @@ function progressText(update) {
53
61
  return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`;
54
62
  }
55
63
 
64
+ function canClaimInteractionReply(message, pending, sender) {
65
+ if (pending.needsPresentation || !pending.questions[pending.index]) return false;
66
+ if (pending.actor !== sender) return false;
67
+ if (String(message?.conversationType) === '2' && message?.isInAtList !== true) return false;
68
+ if (message?.msgtype !== 'text' || !nonEmptyString(message?.text?.content)) return false;
69
+ try {
70
+ normalizeDingtalkSessionWebhook(message.sessionWebhook);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
56
77
  function ensureStats(status) {
57
78
  status.stats ??= {};
58
79
  for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected', 'messagesIgnored']) {
@@ -100,6 +121,8 @@ export class DingtalkHarnessBridge {
100
121
  #maxMessageChars;
101
122
  #signal;
102
123
  #queues = new Map();
124
+ #pendingInteractions = new Map();
125
+ #interactionKeys = new Map();
103
126
  #acceptedMessageIds = new Set();
104
127
 
105
128
  constructor({
@@ -155,12 +178,49 @@ export class DingtalkHarnessBridge {
155
178
  this.#status.lastRejectedAt = new Date().toISOString();
156
179
  return Promise.resolve();
157
180
  }
181
+
182
+ const pending = this.#pendingInteractions.get(key);
183
+ if (pending && pending.actor !== sender) {
184
+ return this.#enqueueMessage(message, messageId, sender, key);
185
+ }
186
+ // Once one valid answer has been claimed, later messages are subsequent
187
+ // prompts even if the network submission eventually needs a retry. Invalid
188
+ // replies do not claim the question, so the next valid answer can still
189
+ // pass through this interaction queue.
190
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
191
+ return this.#enqueueMessage(message, messageId, sender, key);
192
+ }
193
+ if (pending) {
194
+ if (canClaimInteractionReply(message, pending, sender)) {
195
+ pending.claimedReplyMessageId = messageId;
196
+ }
197
+ const previous = pending.queue ?? Promise.resolve();
198
+ const current = previous
199
+ .catch(() => undefined)
200
+ .then(() => this.#processInteractionReply(message, messageId, sender, key, pending))
201
+ .finally(() => {
202
+ this.#acceptedMessageIds.delete(messageId);
203
+ if (pending.claimedReplyMessageId === messageId) {
204
+ pending.claimedReplyMessageId = null;
205
+ }
206
+ if (pending.queue === current) pending.queue = null;
207
+ });
208
+ pending.queue = current;
209
+ return current;
210
+ }
211
+ return this.#enqueueMessage(message, messageId, sender, key);
212
+ }
213
+
214
+ #enqueueMessage(message, messageId, sender, key, {
215
+ releaseMessageId = true,
216
+ alreadyRecorded = false,
217
+ } = {}) {
158
218
  const previous = this.#queues.get(key) ?? Promise.resolve();
159
219
  const current = previous
160
220
  .catch(() => undefined)
161
- .then(() => this.#process(message, messageId, sender, key))
221
+ .then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
162
222
  .finally(() => {
163
- this.#acceptedMessageIds.delete(messageId);
223
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
164
224
  if (this.#queues.get(key) === current) this.#queues.delete(key);
165
225
  });
166
226
  this.#queues.set(key, current);
@@ -168,15 +228,22 @@ export class DingtalkHarnessBridge {
168
228
  }
169
229
 
170
230
  async waitForIdle() {
171
- await Promise.allSettled([...this.#queues.values()]);
231
+ await Promise.allSettled([
232
+ ...this.#queues.values(),
233
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
234
+ pending.queue ? [pending.queue] : []
235
+ )),
236
+ ]);
172
237
  }
173
238
 
174
- async #process(message, messageId, sender, key) {
239
+ async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
175
240
  this.#signal?.throwIfAborted();
176
- if (this.#state.hasSeen(messageId)) return;
177
- await this.#state.markSeen(messageId);
178
- increment(this.#status, 'messagesReceived');
179
- this.#status.lastMessageAt = new Date().toISOString();
241
+ if (!alreadyRecorded) {
242
+ if (this.#state.hasSeen(messageId)) return;
243
+ await this.#state.markSeen(messageId);
244
+ increment(this.#status, 'messagesReceived');
245
+ this.#status.lastMessageAt = new Date().toISOString();
246
+ }
180
247
 
181
248
  if (String(message.conversationType) === '2' && message.isInAtList !== true) {
182
249
  increment(this.#status, 'messagesIgnored');
@@ -217,7 +284,7 @@ export class DingtalkHarnessBridge {
217
284
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
218
285
  return;
219
286
  }
220
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
287
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
221
288
  if (workspaceCommand) {
222
289
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
223
290
  await this.#send(sessionWebhook, reply);
@@ -251,6 +318,13 @@ export class DingtalkHarnessBridge {
251
318
  onUpdate: cardStarted
252
319
  ? (update) => cardStream.push(progressText(update))
253
320
  : undefined,
321
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
322
+ key,
323
+ actor: sender,
324
+ sessionWebhook,
325
+ requiresMention: String(message.conversationType) === '2',
326
+ }),
327
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
254
328
  },
255
329
  });
256
330
  const streamed = cardStarted && await cardStream.finish(answer);
@@ -268,6 +342,286 @@ export class DingtalkHarnessBridge {
268
342
  } catch {
269
343
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
270
344
  }
345
+ } finally {
346
+ await this.#cancelPendingInteraction(key);
347
+ }
348
+ }
349
+
350
+ async #processInteractionReply(message, messageId, sender, key, expected) {
351
+ this.#signal?.throwIfAborted();
352
+ const current = this.#pendingInteractions.get(key);
353
+ const claimed = expected.claimedReplyMessageId === messageId;
354
+ if (!current || current !== expected || current.submitting) {
355
+ if (claimed && (!current || current !== expected)) {
356
+ return this.#discardResolvedInteractionReply(message, messageId);
357
+ }
358
+ return this.#enqueueMessage(message, messageId, sender, key, { releaseMessageId: false });
359
+ }
360
+ if (this.#state.hasSeen(messageId)) return;
361
+ await this.#state.markSeen(messageId);
362
+ increment(this.#status, 'messagesReceived');
363
+ this.#status.lastMessageAt = new Date().toISOString();
364
+
365
+ if (String(message.conversationType) === '2' && message.isInAtList !== true) {
366
+ increment(this.#status, 'messagesIgnored');
367
+ return;
368
+ }
369
+
370
+ let sessionWebhook;
371
+ try {
372
+ sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook);
373
+ } catch {
374
+ increment(this.#status, 'messagesRejected');
375
+ this.#status.lastRejectedAt = new Date().toISOString();
376
+ this.#status.lastError = '钉钉消息没有安全的回复地址。';
377
+ return;
378
+ }
379
+
380
+ const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
381
+ if (!text) {
382
+ try {
383
+ await this.#send(sessionWebhook, '请用文字回答当前问题。');
384
+ } catch {
385
+ this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
386
+ }
387
+ return;
388
+ }
389
+
390
+ const pending = this.#pendingInteractions.get(key);
391
+ if (!pending || pending !== expected || pending.submitting) {
392
+ if (claimed && (!pending || pending !== expected)) {
393
+ try {
394
+ await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
395
+ } catch {
396
+ this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
397
+ }
398
+ return;
399
+ }
400
+ return this.#enqueueMessage(message, messageId, sender, key, {
401
+ releaseMessageId: false,
402
+ alreadyRecorded: true,
403
+ });
404
+ }
405
+ pending.sessionWebhook = sessionWebhook;
406
+ if (pending.needsPresentation) {
407
+ try {
408
+ await this.#presentInteraction(pending);
409
+ } catch {
410
+ this.#status.lastError = '钉钉交互问题发送失败。';
411
+ this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
412
+ pending.interaction.reconnect?.();
413
+ }
414
+ return;
415
+ }
416
+ const question = pending.questions[pending.index];
417
+ if (!question) return;
418
+
419
+ pending.answers.push(harnessAnswerForQuestion(question, text));
420
+ pending.index += 1;
421
+ if (pending.index < pending.questions.length) {
422
+ if (pending.claimedReplyMessageId === messageId) {
423
+ pending.claimedReplyMessageId = null;
424
+ }
425
+ pending.needsPresentation = true;
426
+ try {
427
+ await this.#presentInteraction(pending);
428
+ } catch {
429
+ this.#status.lastError = '钉钉交互问题发送失败。';
430
+ this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
431
+ pending.interaction.reconnect?.();
432
+ }
433
+ return;
434
+ }
435
+
436
+ pending.submitting = true;
437
+ try {
438
+ await pending.interaction.respond({
439
+ ok: true,
440
+ value: {
441
+ sessionId: pending.sessionId,
442
+ answer: { answers: pending.answers },
443
+ },
444
+ });
445
+ this.#clearPendingInteraction(key, pending.interactionId);
446
+ this.#status.lastError = null;
447
+ } catch (error) {
448
+ if (this.#signal?.aborted) return;
449
+ if (this.#pendingInteractions.get(key) !== pending) return;
450
+ if (error?.code === 'interaction-not-pending') {
451
+ this.#clearPendingInteraction(key, pending.interactionId);
452
+ try {
453
+ await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
454
+ } catch {
455
+ this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
456
+ }
457
+ return;
458
+ }
459
+ pending.submitting = false;
460
+ pending.answers.pop();
461
+ pending.index -= 1;
462
+ this.#status.lastError = '回答提交失败。';
463
+ this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
464
+ try {
465
+ await this.#send(sessionWebhook, '回答提交失败,请重新发送当前问题的答案。');
466
+ } catch {
467
+ this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
468
+ }
469
+ }
470
+ }
471
+
472
+ async #handleInteraction(interaction, {
473
+ key,
474
+ actor,
475
+ sessionWebhook,
476
+ requiresMention,
477
+ }) {
478
+ // The transport deliberately exposes every interaction kind. Approval is
479
+ // left unanswered until #5 adds its own policy and renderer.
480
+ if (interaction?.kind !== 'question') return;
481
+ const questions = interaction?.payload?.questions;
482
+ const interactionId = typeof interaction?.interactionId === 'string'
483
+ ? interaction.interactionId
484
+ : interaction?.rpcId;
485
+ if (typeof interaction.rpcId !== 'string'
486
+ || typeof interactionId !== 'string'
487
+ || typeof interaction.sessionId !== 'string'
488
+ || !Array.isArray(questions)
489
+ || questions.length === 0
490
+ || questions.some((question) => !validHarnessQuestion(question))) {
491
+ this.#logger.warn?.('[dsh-dingtalk] ignored an invalid Harness question interaction');
492
+ return;
493
+ }
494
+
495
+ if (interaction.recovered === true) {
496
+ await interaction.respond({
497
+ ok: false,
498
+ error: {
499
+ code: 'cancelled',
500
+ message: 'DingTalk safely cancelled an interaction left by an earlier client.',
501
+ details: {},
502
+ },
503
+ });
504
+ try {
505
+ await this.#send(sessionWebhook, '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。');
506
+ } catch {
507
+ this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice');
508
+ }
509
+ return;
510
+ }
511
+
512
+ const existing = this.#pendingInteractions.get(key);
513
+ if (existing?.interactionId === interactionId) {
514
+ existing.interaction = interaction;
515
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
516
+ return;
517
+ }
518
+ if (this.#interactionKeys.has(interactionId)) return;
519
+ if (existing) {
520
+ this.#logger.warn?.('[dsh-dingtalk] cancelled a second pending Harness question');
521
+ await interaction.respond({
522
+ ok: false,
523
+ error: {
524
+ code: 'cancelled',
525
+ message: 'DingTalk is already handling another user interaction.',
526
+ details: {},
527
+ },
528
+ });
529
+ return;
530
+ }
531
+
532
+ const pending = {
533
+ kind: 'question',
534
+ interactionId,
535
+ sessionId: interaction.sessionId,
536
+ interaction,
537
+ actor,
538
+ requiresMention,
539
+ questions,
540
+ answers: [],
541
+ index: 0,
542
+ sessionWebhook,
543
+ queue: null,
544
+ claimedReplyMessageId: null,
545
+ submitting: false,
546
+ needsPresentation: true,
547
+ };
548
+ this.#pendingInteractions.set(key, pending);
549
+ this.#interactionKeys.set(pending.interactionId, key);
550
+ await this.#presentInteraction(pending);
551
+ }
552
+
553
+ #handleInteractionResolved(resolution) {
554
+ const interactionId = resolution?.interactionId;
555
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
556
+ const key = this.#interactionKeys.get(interactionId);
557
+ if (!key) return;
558
+ this.#clearPendingInteraction(key, interactionId);
559
+ }
560
+
561
+ async #presentInteraction(pending) {
562
+ const question = pending.questions[pending.index];
563
+ if (!question) return;
564
+ await this.#send(
565
+ pending.sessionWebhook,
566
+ harnessQuestionText(
567
+ question,
568
+ pending.index,
569
+ pending.questions.length,
570
+ { requiresMention: pending.requiresMention },
571
+ ),
572
+ );
573
+ pending.needsPresentation = false;
574
+ }
575
+
576
+ async #discardResolvedInteractionReply(message, messageId) {
577
+ if (this.#state.hasSeen(messageId)) return;
578
+ await this.#state.markSeen(messageId);
579
+ increment(this.#status, 'messagesReceived');
580
+ this.#status.lastMessageAt = new Date().toISOString();
581
+ let sessionWebhook;
582
+ try {
583
+ sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook);
584
+ } catch {
585
+ increment(this.#status, 'messagesRejected');
586
+ this.#status.lastRejectedAt = new Date().toISOString();
587
+ return;
588
+ }
589
+ try {
590
+ await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
591
+ } catch {
592
+ this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
593
+ }
594
+ }
595
+
596
+ #takePendingInteraction(key, interactionId) {
597
+ const pending = this.#pendingInteractions.get(key);
598
+ if (!pending
599
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
600
+ this.#pendingInteractions.delete(key);
601
+ this.#interactionKeys.delete(pending.interactionId);
602
+ return pending;
603
+ }
604
+
605
+ #clearPendingInteraction(key, interactionId) {
606
+ return this.#takePendingInteraction(key, interactionId) !== null;
607
+ }
608
+
609
+ async #cancelPendingInteraction(key) {
610
+ const pending = this.#takePendingInteraction(key);
611
+ if (!pending || pending.kind !== 'question') return;
612
+ try {
613
+ await pending.interaction.respond({
614
+ ok: false,
615
+ error: {
616
+ code: 'cancelled',
617
+ message: 'The DingTalk interaction ended before the user answered.',
618
+ details: {},
619
+ },
620
+ }, { signal: AbortSignal.timeout(5_000) });
621
+ } catch (error) {
622
+ if (error?.code !== 'interaction-not-pending') {
623
+ this.#logger.warn?.('[dsh-dingtalk] failed to cancel a pending Harness interaction');
624
+ }
271
625
  }
272
626
  }
273
627