@xmanrui/dsh-im 0.7.1 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import {
8
8
  harnessQuestionText,
9
9
  validHarnessQuestion,
10
10
  } from '../shared/harness-question.mjs';
11
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
11
12
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
12
13
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
13
14
 
@@ -123,7 +124,9 @@ export class DingtalkHarnessBridge {
123
124
  #queues = new Map();
124
125
  #pendingInteractions = new Map();
125
126
  #interactionKeys = new Map();
127
+ #interactionTasks = new Set();
126
128
  #acceptedMessageIds = new Set();
129
+ #approvals;
127
130
 
128
131
  constructor({
129
132
  api,
@@ -149,6 +152,7 @@ export class DingtalkHarnessBridge {
149
152
  this.#state = state;
150
153
  this.#status = status;
151
154
  this.#logger = logger;
155
+ this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
152
156
  this.#replyTimeoutMs = replyTimeoutMs;
153
157
  this.#maxMessageChars = maxMessageChars;
154
158
  this.#signal = signal;
@@ -179,7 +183,57 @@ export class DingtalkHarnessBridge {
179
183
  return Promise.resolve();
180
184
  }
181
185
 
186
+ let sessionWebhook = null;
187
+ try {
188
+ sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook);
189
+ } catch {
190
+ // An unsafe reply route must never be able to submit an approval.
191
+ }
182
192
  const pending = this.#pendingInteractions.get(key);
193
+ const approvalReply = this.#approvals.claimReply({
194
+ key,
195
+ actor: sender,
196
+ messageId,
197
+ text: sessionWebhook && message?.msgtype === 'text'
198
+ ? nonEmptyString(message?.text?.content) ?? ''
199
+ : '',
200
+ addressed: String(message?.conversationType) !== '2' || message?.isInAtList === true,
201
+ hasPendingQuestion: Boolean(pending),
202
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
203
+ ? pending.queue
204
+ : null,
205
+ isQuestionPending: () => this.#pendingInteractions.has(key),
206
+ send: sessionWebhook
207
+ ? (reply) => this.#send(sessionWebhook, reply)
208
+ : async () => undefined,
209
+ });
210
+ if (approvalReply) {
211
+ let current;
212
+ current = approvalReply.process(async () => {
213
+ if (this.#state.hasSeen(messageId)) return false;
214
+ await this.#state.markSeen(messageId);
215
+ increment(this.#status, 'messagesReceived');
216
+ this.#status.lastMessageAt = new Date().toISOString();
217
+ if (!sessionWebhook) {
218
+ increment(this.#status, 'messagesRejected');
219
+ this.#status.lastRejectedAt = new Date().toISOString();
220
+ this.#status.lastError = '钉钉消息没有安全的回复地址。';
221
+ }
222
+ return true;
223
+ })
224
+ .catch((error) => {
225
+ if (this.#signal?.aborted) return;
226
+ this.#status.lastError = '钉钉审批处理失败。';
227
+ this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
228
+ })
229
+ .finally(() => {
230
+ this.#acceptedMessageIds.delete(messageId);
231
+ this.#interactionTasks.delete(current);
232
+ });
233
+ this.#interactionTasks.add(current);
234
+ return current;
235
+ }
236
+
183
237
  if (pending && pending.actor !== sender) {
184
238
  return this.#enqueueMessage(message, messageId, sender, key);
185
239
  }
@@ -233,6 +287,7 @@ export class DingtalkHarnessBridge {
233
287
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
234
288
  pending.queue ? [pending.queue] : []
235
289
  )),
290
+ ...this.#interactionTasks,
236
291
  ]);
237
292
  }
238
293
 
@@ -344,6 +399,7 @@ export class DingtalkHarnessBridge {
344
399
  }
345
400
  } finally {
346
401
  await this.#cancelPendingInteraction(key);
402
+ await this.#approvals.closeRoute(key);
347
403
  }
348
404
  }
349
405
 
@@ -475,8 +531,14 @@ export class DingtalkHarnessBridge {
475
531
  sessionWebhook,
476
532
  requiresMention,
477
533
  }) {
478
- // The transport deliberately exposes every interaction kind. Approval is
479
- // left unanswered until #5 adds its own policy and renderer.
534
+ if (await this.#approvals.handleRequested(interaction, {
535
+ key,
536
+ actor,
537
+ requiresMention,
538
+ send: (text) => this.#send(sessionWebhook, text),
539
+ })) return;
540
+
541
+ // Approval requests return above; the existing question state machine stays unchanged.
480
542
  if (interaction?.kind !== 'question') return;
481
543
  const questions = interaction?.payload?.questions;
482
544
  const interactionId = typeof interaction?.interactionId === 'string'
@@ -550,7 +612,8 @@ export class DingtalkHarnessBridge {
550
612
  await this.#presentInteraction(pending);
551
613
  }
552
614
 
553
- #handleInteractionResolved(resolution) {
615
+ async #handleInteractionResolved(resolution) {
616
+ if (await this.#approvals.handleResolved(resolution)) return;
554
617
  const interactionId = resolution?.interactionId;
555
618
  if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
556
619
  const key = this.#interactionKeys.get(interactionId);
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.1)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.7.2)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -10,6 +10,7 @@ import {
10
10
  harnessQuestionText,
11
11
  validHarnessQuestion,
12
12
  } from '../shared/harness-question.mjs';
13
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
13
14
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
14
15
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
15
16
 
@@ -67,6 +68,7 @@ export class FeishuHarnessBridge {
67
68
  #resolvedQuestionReplies = new Map();
68
69
  #acceptedMessageIds = new Set();
69
70
  #interactionTasks = new Set();
71
+ #approvals;
70
72
  #status;
71
73
  #allowedSenderOpenIds;
72
74
  #replyTimeoutMs;
@@ -95,6 +97,7 @@ export class FeishuHarnessBridge {
95
97
  this.#allowedSenderOpenIds = allowedSenderOpenIds;
96
98
  this.#replyTimeoutMs = replyTimeoutMs;
97
99
  this.#logger = logger;
100
+ this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
98
101
  this.#signal = signal;
99
102
  ensureStatus(this.#status);
100
103
  }
@@ -138,6 +141,44 @@ export class FeishuHarnessBridge {
138
141
  return current;
139
142
  }
140
143
  const pending = this.#pendingInteractions.get(key);
144
+ const approvalReply = this.#approvals.claimReply({
145
+ key,
146
+ actor: senderOpenId(event),
147
+ messageId,
148
+ text: extractText(event) ?? '',
149
+ addressed: event?.message?.chat_type === 'p2p'
150
+ || (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0),
151
+ hasPendingQuestion: Boolean(pending),
152
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
153
+ ? pending.queue
154
+ : null,
155
+ isQuestionPending: () => this.#pendingInteractions.has(key),
156
+ send: (text) => this.#send(event.message.chat_id, text),
157
+ });
158
+ if (approvalReply) {
159
+ const processing = approvalReply.process(async () => {
160
+ if (this.#state.hasSeen(messageId)) return false;
161
+ await this.#state.markSeen(messageId);
162
+ this.#status.lastMessageAt = new Date().toISOString();
163
+ this.#status.messagesReceived += 1;
164
+ return true;
165
+ });
166
+ let current;
167
+ current = processing
168
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
169
+ .catch((error) => this.#handleMessageFailure(
170
+ event,
171
+ messageId,
172
+ processingReaction,
173
+ error,
174
+ ))
175
+ .finally(() => {
176
+ this.#acceptedMessageIds.delete(messageId);
177
+ this.#interactionTasks.delete(current);
178
+ });
179
+ this.#interactionTasks.add(current);
180
+ return current;
181
+ }
141
182
  if (pending && senderOpenId(event) !== pending.actor) {
142
183
  return this.#enqueueMessage(event, messageId, key, processingReaction);
143
184
  }
@@ -290,6 +331,7 @@ export class FeishuHarnessBridge {
290
331
  this.#status.lastError = null;
291
332
  } finally {
292
333
  await this.#cancelPendingInteraction(key);
334
+ await this.#approvals.closeRoute(key);
293
335
  }
294
336
  }
295
337
 
@@ -490,8 +532,14 @@ export class FeishuHarnessBridge {
490
532
  chatId,
491
533
  requiresMention,
492
534
  }) {
493
- // Approval is deliberately exposed by the transport but remains
494
- // unanswered until #5 adds an authenticated policy and renderer.
535
+ if (await this.#approvals.handleRequested(interaction, {
536
+ key,
537
+ actor,
538
+ requiresMention,
539
+ send: (text) => this.#send(chatId, text),
540
+ })) return;
541
+
542
+ // Approval requests return above; the existing question state machine stays unchanged.
495
543
  if (interaction?.kind !== 'question') return;
496
544
  const questions = interaction?.payload?.questions;
497
545
  const interactionId = typeof interaction?.interactionId === 'string'
@@ -566,7 +614,8 @@ export class FeishuHarnessBridge {
566
614
  await this.#presentInteraction(pending);
567
615
  }
568
616
 
569
- #handleInteractionResolved(resolution) {
617
+ async #handleInteractionResolved(resolution) {
618
+ if (await this.#approvals.handleResolved(resolution)) return;
570
619
  const interactionId = resolution?.interactionId;
571
620
  if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
572
621
  const key = this.#interactionKeys.get(interactionId);
@@ -4,6 +4,7 @@ import {
4
4
  harnessQuestionText,
5
5
  validHarnessQuestion,
6
6
  } from '../shared/harness-question.mjs';
7
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
7
8
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
8
9
 
9
10
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
@@ -65,6 +66,8 @@ export class QqHarnessBridge {
65
66
  #pendingInteractions = new Map();
66
67
  #interactionKeys = new Map();
67
68
  #acceptedMessageIds = new Set();
69
+ #approvalTasks = new Set();
70
+ #approvals;
68
71
 
69
72
  constructor({
70
73
  bot,
@@ -87,6 +90,7 @@ export class QqHarnessBridge {
87
90
  this.#logger = logger;
88
91
  this.#replyTimeoutMs = replyTimeoutMs;
89
92
  this.#signal = signal;
93
+ this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
90
94
  }
91
95
 
92
96
  get status() {
@@ -104,6 +108,35 @@ export class QqHarnessBridge {
104
108
  const key = conversationKey(message);
105
109
  this.#acceptedMessageIds.add(messageId);
106
110
  const pending = this.#pendingInteractions.get(key);
111
+ const approval = this.#approvals.claimReply({
112
+ key,
113
+ actor: sender,
114
+ messageId,
115
+ text: safeText(message),
116
+ addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
117
+ hasPendingQuestion: Boolean(pending),
118
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
119
+ ? pending.queue
120
+ : null,
121
+ isQuestionPending: () => this.#pendingInteractions.has(key),
122
+ send: (text) => this.#bot.sendText(message.replyTarget, text),
123
+ });
124
+ if (approval) {
125
+ let task;
126
+ task = approval.process(async () => {
127
+ if (this.#state.hasSeen(messageId)) return false;
128
+ await this.#state.markSeen(messageId);
129
+ this.#status.messagesReceived += 1;
130
+ this.#status.lastMessageAt = new Date().toISOString();
131
+ return true;
132
+ })
133
+ .finally(() => {
134
+ this.#acceptedMessageIds.delete(messageId);
135
+ this.#approvalTasks.delete(task);
136
+ });
137
+ this.#approvalTasks.add(task);
138
+ return task;
139
+ }
107
140
  if (pending && sender !== pending.actor) {
108
141
  return this.#enqueueMessage(message, messageId, key);
109
142
  }
@@ -152,6 +185,7 @@ export class QqHarnessBridge {
152
185
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
153
186
  pending.queue ? [pending.queue] : []
154
187
  )),
188
+ ...this.#approvalTasks,
155
189
  ]);
156
190
  }
157
191
 
@@ -247,7 +281,10 @@ export class QqHarnessBridge {
247
281
  },
248
282
  }));
249
283
  } finally {
250
- await this.#cancelPendingInteraction(key);
284
+ await Promise.allSettled([
285
+ this.#cancelPendingInteraction(key),
286
+ this.#approvals.closeRoute(key),
287
+ ]);
251
288
  }
252
289
  if (stream) {
253
290
  try {
@@ -388,7 +425,14 @@ export class QqHarnessBridge {
388
425
  target,
389
426
  requiresMention,
390
427
  }) {
391
- // Approval remains fail-closed until #5 adds an authenticated policy.
428
+ if (interaction?.kind === 'approval') {
429
+ return this.#approvals.handleRequested(interaction, {
430
+ key,
431
+ actor,
432
+ requiresMention,
433
+ send: (text) => this.#bot.sendText(target, text),
434
+ });
435
+ }
392
436
  if (interaction?.kind !== 'question') return;
393
437
  const questions = interaction?.payload?.questions;
394
438
  const interactionId = typeof interaction?.interactionId === 'string'
@@ -461,7 +505,11 @@ export class QqHarnessBridge {
461
505
  await this.#presentInteraction(pending);
462
506
  }
463
507
 
464
- #handleInteractionResolved(resolution) {
508
+ async #handleInteractionResolved(resolution) {
509
+ if (resolution?.kind === 'approval') {
510
+ await this.#approvals.handleResolved(resolution);
511
+ return;
512
+ }
465
513
  const interactionId = resolution?.interactionId;
466
514
  if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
467
515
  const key = this.#interactionKeys.get(interactionId);