@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.
- package/lib/index.js +118 -121
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +360 -8
- package/src/channels/dingtalk/harness-client.mjs +16 -366
- 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 +522 -50
- package/src/channels/feishu/feishu-runtime.mjs +41 -1
- package/src/channels/feishu/harness-client.mjs +16 -335
- package/src/channels/qq/harness-client.mjs +10 -2
- package/src/channels/qq/qq-bridge.mjs +380 -28
- package/src/channels/qq/qq-runtime.mjs +14 -3
- package/src/channels/shared/harness-client.mjs +825 -0
- package/src/channels/shared/harness-question.mjs +85 -0
- package/src/channels/shared/text-harness-bridge.mjs +436 -24
- 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 +386 -14
- package/src/channels/wecom/wecom-runtime.mjs +6 -0
- package/src/channels/weixin/harness-client.mjs +16 -326
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +402 -22
- 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
|
@@ -5,9 +5,17 @@ import {
|
|
|
5
5
|
isBotSender,
|
|
6
6
|
splitText,
|
|
7
7
|
} from './message-utils.mjs';
|
|
8
|
+
import {
|
|
9
|
+
harnessAnswerForQuestion,
|
|
10
|
+
harnessQuestionText,
|
|
11
|
+
validHarnessQuestion,
|
|
12
|
+
} from '../shared/harness-question.mjs';
|
|
8
13
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
9
14
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
10
15
|
|
|
16
|
+
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
17
|
+
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
18
|
+
|
|
11
19
|
const HELP_TEXT = [
|
|
12
20
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
|
13
21
|
'',
|
|
@@ -21,16 +29,49 @@ const HELP_TEXT = [
|
|
|
21
29
|
'/help 显示本帮助',
|
|
22
30
|
].join('\n');
|
|
23
31
|
|
|
32
|
+
function nonEmptyString(value) {
|
|
33
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function senderOpenId(event) {
|
|
37
|
+
return nonEmptyString(event?.sender?.sender_id?.open_id)
|
|
38
|
+
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canClaimInteractionReply(event, pending) {
|
|
42
|
+
return pending.needsPresentation !== true
|
|
43
|
+
&& pending.questions[pending.index]
|
|
44
|
+
&& senderOpenId(event) === pending.actor
|
|
45
|
+
&& event?.message?.message_type === 'text'
|
|
46
|
+
&& nonEmptyString(extractText(event));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureStatus(status) {
|
|
50
|
+
for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected']) {
|
|
51
|
+
status[key] ??= 0;
|
|
52
|
+
}
|
|
53
|
+
status.lastMessageAt ??= null;
|
|
54
|
+
status.lastReplyAt ??= null;
|
|
55
|
+
status.lastRejectedAt ??= null;
|
|
56
|
+
status.lastError ??= null;
|
|
57
|
+
}
|
|
58
|
+
|
|
24
59
|
export class FeishuHarnessBridge {
|
|
25
60
|
#client;
|
|
26
61
|
#channel;
|
|
27
62
|
#harness;
|
|
28
63
|
#state;
|
|
29
64
|
#queues = new Map();
|
|
65
|
+
#pendingInteractions = new Map();
|
|
66
|
+
#interactionKeys = new Map();
|
|
67
|
+
#resolvedQuestionReplies = new Map();
|
|
30
68
|
#acceptedMessageIds = new Set();
|
|
69
|
+
#interactionTasks = new Set();
|
|
31
70
|
#status;
|
|
32
71
|
#allowedSenderOpenIds;
|
|
33
72
|
#replyTimeoutMs;
|
|
73
|
+
#logger;
|
|
74
|
+
#signal;
|
|
34
75
|
|
|
35
76
|
constructor({
|
|
36
77
|
client,
|
|
@@ -39,8 +80,13 @@ export class FeishuHarnessBridge {
|
|
|
39
80
|
state,
|
|
40
81
|
status,
|
|
41
82
|
allowedSenderOpenIds = new Set(),
|
|
42
|
-
replyTimeoutMs =
|
|
83
|
+
replyTimeoutMs = 600_000,
|
|
84
|
+
logger = console,
|
|
85
|
+
signal,
|
|
43
86
|
}) {
|
|
87
|
+
if (!client || !harness || !state || !status) {
|
|
88
|
+
throw new TypeError('Feishu bridge dependencies are required');
|
|
89
|
+
}
|
|
44
90
|
this.#client = client;
|
|
45
91
|
this.#channel = channel;
|
|
46
92
|
this.#harness = harness;
|
|
@@ -48,55 +94,171 @@ export class FeishuHarnessBridge {
|
|
|
48
94
|
this.#status = status;
|
|
49
95
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
|
50
96
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
97
|
+
this.#logger = logger;
|
|
98
|
+
this.#signal = signal;
|
|
99
|
+
ensureStatus(this.#status);
|
|
51
100
|
}
|
|
52
101
|
|
|
53
102
|
accept(event) {
|
|
54
|
-
|
|
55
|
-
|
|
103
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
104
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
105
|
+
if (!messageId || isBotSender(event)) return Promise.resolve();
|
|
56
106
|
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
|
|
57
107
|
this.#status.messagesRejected += 1;
|
|
58
108
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
59
|
-
|
|
60
|
-
return;
|
|
109
|
+
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
}
|
|
112
|
+
if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let key;
|
|
117
|
+
try {
|
|
118
|
+
key = conversationKey(event);
|
|
119
|
+
} catch {
|
|
120
|
+
this.#status.messagesRejected += 1;
|
|
121
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
122
|
+
return Promise.resolve();
|
|
61
123
|
}
|
|
62
|
-
|
|
124
|
+
|
|
63
125
|
this.#acceptedMessageIds.add(messageId);
|
|
64
126
|
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
|
127
|
+
if (this.#isResolvedQuestionReply(event, key)) {
|
|
128
|
+
const current = Promise.resolve()
|
|
129
|
+
.then(() => this.#discardResolvedInteractionReply(event, messageId))
|
|
130
|
+
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
|
131
|
+
.catch((error) => this.#handleMessageFailure(
|
|
132
|
+
event,
|
|
133
|
+
messageId,
|
|
134
|
+
processingReaction,
|
|
135
|
+
error,
|
|
136
|
+
))
|
|
137
|
+
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
|
138
|
+
return current;
|
|
139
|
+
}
|
|
140
|
+
const pending = this.#pendingInteractions.get(key);
|
|
141
|
+
if (pending && senderOpenId(event) !== pending.actor) {
|
|
142
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
143
|
+
}
|
|
144
|
+
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
145
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
146
|
+
}
|
|
147
|
+
if (pending) {
|
|
148
|
+
if (canClaimInteractionReply(event, pending)) pending.claimedReplyMessageId = messageId;
|
|
149
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
150
|
+
const processing = previous
|
|
151
|
+
.catch(() => undefined)
|
|
152
|
+
.then(() => this.#processInteractionReply(
|
|
153
|
+
event,
|
|
154
|
+
messageId,
|
|
155
|
+
key,
|
|
156
|
+
pending,
|
|
157
|
+
processingReaction,
|
|
158
|
+
));
|
|
159
|
+
pending.queue = processing;
|
|
160
|
+
|
|
161
|
+
const releaseInteraction = () => {
|
|
162
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
163
|
+
pending.claimedReplyMessageId = null;
|
|
164
|
+
}
|
|
165
|
+
if (pending.queue === processing) pending.queue = null;
|
|
166
|
+
};
|
|
167
|
+
let current;
|
|
168
|
+
current = processing
|
|
169
|
+
.then(
|
|
170
|
+
() => {
|
|
171
|
+
releaseInteraction();
|
|
172
|
+
return this.#finishReaction(messageId, processingReaction, 'DONE');
|
|
173
|
+
},
|
|
174
|
+
(error) => {
|
|
175
|
+
releaseInteraction();
|
|
176
|
+
return this.#handleMessageFailure(
|
|
177
|
+
event,
|
|
178
|
+
messageId,
|
|
179
|
+
processingReaction,
|
|
180
|
+
error,
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
.finally(() => {
|
|
185
|
+
releaseInteraction();
|
|
186
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
187
|
+
this.#interactionTasks.delete(current);
|
|
188
|
+
});
|
|
189
|
+
this.#interactionTasks.add(current);
|
|
190
|
+
return current;
|
|
191
|
+
}
|
|
192
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
193
|
+
}
|
|
65
194
|
|
|
66
|
-
|
|
195
|
+
#enqueueMessage(event, messageId, key, processingReaction, {
|
|
196
|
+
releaseMessageId = true,
|
|
197
|
+
alreadyRecorded = false,
|
|
198
|
+
finalize = true,
|
|
199
|
+
} = {}) {
|
|
67
200
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
68
|
-
const
|
|
201
|
+
const work = previous
|
|
69
202
|
.catch(() => undefined)
|
|
70
|
-
.then(() => this.#handle(event, key))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
203
|
+
.then(() => this.#handle(event, key, { alreadyRecorded }));
|
|
204
|
+
const settled = finalize
|
|
205
|
+
? work
|
|
206
|
+
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
|
207
|
+
.catch((error) => this.#handleMessageFailure(
|
|
208
|
+
event,
|
|
209
|
+
messageId,
|
|
210
|
+
processingReaction,
|
|
211
|
+
error,
|
|
212
|
+
))
|
|
213
|
+
: work;
|
|
214
|
+
let current;
|
|
215
|
+
current = settled.finally(() => {
|
|
216
|
+
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
|
217
|
+
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
|
218
|
+
});
|
|
219
|
+
this.#queues.set(key, current);
|
|
220
|
+
return current;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async #handleMessageFailure(event, messageId, processingReaction, error) {
|
|
224
|
+
if (this.#signal?.aborted) {
|
|
225
|
+
await this.#removeProcessingReaction(messageId, processingReaction);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.#logger.error?.('[dsh-feishu] message handling failed:', error?.message ?? String(error));
|
|
229
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
230
|
+
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
|
231
|
+
await this.#send(
|
|
232
|
+
event.message.chat_id,
|
|
233
|
+
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
|
234
|
+
).catch(() => undefined);
|
|
86
235
|
}
|
|
87
236
|
|
|
88
237
|
async waitForIdle() {
|
|
89
|
-
await Promise.allSettled([
|
|
238
|
+
await Promise.allSettled([
|
|
239
|
+
...this.#queues.values(),
|
|
240
|
+
...[...this.#pendingInteractions.values()].flatMap((pending) => (
|
|
241
|
+
pending.queue ? [pending.queue] : []
|
|
242
|
+
)),
|
|
243
|
+
...this.#interactionTasks,
|
|
244
|
+
]);
|
|
90
245
|
}
|
|
91
246
|
|
|
92
|
-
async #handle(event, key) {
|
|
247
|
+
async #handle(event, key, { alreadyRecorded = false } = {}) {
|
|
248
|
+
this.#signal?.throwIfAborted();
|
|
93
249
|
const messageId = event.message.message_id;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
250
|
+
if (!alreadyRecorded) {
|
|
251
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
252
|
+
await this.#state.markSeen(messageId);
|
|
253
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
254
|
+
this.#status.messagesReceived += 1;
|
|
255
|
+
}
|
|
97
256
|
|
|
98
257
|
const text = extractText(event);
|
|
99
|
-
if (!text)
|
|
258
|
+
if (!text) {
|
|
259
|
+
await this.#send(event.message.chat_id, '目前仅支持文字消息。');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
100
262
|
|
|
101
263
|
if (text === '/help') {
|
|
102
264
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
@@ -108,7 +270,7 @@ export class FeishuHarnessBridge {
|
|
|
108
270
|
return;
|
|
109
271
|
}
|
|
110
272
|
if (text === '/status') {
|
|
111
|
-
await this.#harness.ensureRunning();
|
|
273
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
112
274
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
113
275
|
return;
|
|
114
276
|
}
|
|
@@ -120,11 +282,29 @@ export class FeishuHarnessBridge {
|
|
|
120
282
|
return;
|
|
121
283
|
}
|
|
122
284
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
285
|
+
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
|
286
|
+
try {
|
|
287
|
+
await this.#answerWithStream(event, key, text);
|
|
288
|
+
this.#status.messagesReplied += 1;
|
|
289
|
+
this.#status.lastReplyAt = new Date().toISOString();
|
|
290
|
+
this.#status.lastError = null;
|
|
291
|
+
} finally {
|
|
292
|
+
await this.#cancelPendingInteraction(key);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#interactionAskOptions(event, key) {
|
|
297
|
+
return {
|
|
298
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
299
|
+
signal: this.#signal,
|
|
300
|
+
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
301
|
+
key,
|
|
302
|
+
actor: senderOpenId(event),
|
|
303
|
+
chatId: event.message.chat_id,
|
|
304
|
+
requiresMention: event.message.chat_type !== 'p2p',
|
|
305
|
+
}),
|
|
306
|
+
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
307
|
+
};
|
|
128
308
|
}
|
|
129
309
|
|
|
130
310
|
async #answerWithStream(event, key, text) {
|
|
@@ -136,7 +316,9 @@ export class FeishuHarnessBridge {
|
|
|
136
316
|
state: this.#state,
|
|
137
317
|
key,
|
|
138
318
|
text,
|
|
139
|
-
|
|
319
|
+
createOptions: { signal: this.#signal },
|
|
320
|
+
existsOptions: { signal: this.#signal },
|
|
321
|
+
askOptions: this.#interactionAskOptions(event, key),
|
|
140
322
|
});
|
|
141
323
|
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
|
142
324
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
@@ -149,18 +331,21 @@ export class FeishuHarnessBridge {
|
|
|
149
331
|
await this.#channel.stream(chatId, {
|
|
150
332
|
markdown: async (controller) => {
|
|
151
333
|
promptStarted = true;
|
|
334
|
+
const askOptions = {
|
|
335
|
+
...this.#interactionAskOptions(event, key),
|
|
336
|
+
onUpdate: async (update) => {
|
|
337
|
+
await controller.setContent(this.#progressText(update));
|
|
338
|
+
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
152
341
|
({ answer: completedAnswer } = await askInWorkspaceSession({
|
|
153
342
|
harness: this.#harness,
|
|
154
343
|
state: this.#state,
|
|
155
344
|
key,
|
|
156
345
|
text,
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await controller.setContent(this.#progressText(update));
|
|
161
|
-
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
162
|
-
},
|
|
163
|
-
},
|
|
346
|
+
createOptions: { signal: this.#signal },
|
|
347
|
+
existsOptions: { signal: this.#signal },
|
|
348
|
+
askOptions,
|
|
164
349
|
}));
|
|
165
350
|
await controller.setContent(completedAnswer);
|
|
166
351
|
},
|
|
@@ -169,26 +354,308 @@ export class FeishuHarnessBridge {
|
|
|
169
354
|
} catch (error) {
|
|
170
355
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
171
356
|
if (completedAnswer) {
|
|
172
|
-
|
|
357
|
+
this.#logger.warn?.(
|
|
358
|
+
'[dsh-feishu] native stream failed after generation; sending final text:',
|
|
359
|
+
error.message,
|
|
360
|
+
);
|
|
173
361
|
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
|
|
174
362
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
175
363
|
return;
|
|
176
364
|
}
|
|
177
365
|
if (promptStarted) throw error;
|
|
178
366
|
|
|
179
|
-
|
|
367
|
+
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
|
180
368
|
const { answer } = await askInWorkspaceSession({
|
|
181
369
|
harness: this.#harness,
|
|
182
370
|
state: this.#state,
|
|
183
371
|
key,
|
|
184
372
|
text,
|
|
185
|
-
|
|
373
|
+
createOptions: { signal: this.#signal },
|
|
374
|
+
existsOptions: { signal: this.#signal },
|
|
375
|
+
askOptions: this.#interactionAskOptions(event, key),
|
|
186
376
|
});
|
|
187
377
|
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
|
188
378
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
189
379
|
}
|
|
190
380
|
}
|
|
191
381
|
|
|
382
|
+
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
|
383
|
+
this.#signal?.throwIfAborted();
|
|
384
|
+
const current = this.#pendingInteractions.get(key);
|
|
385
|
+
const claimed = expected.claimedReplyMessageId === messageId;
|
|
386
|
+
if (!current || current !== expected || current.submitting) {
|
|
387
|
+
if (this.#isResolvedQuestionReply(event, key)) {
|
|
388
|
+
return this.#discardResolvedInteractionReply(event, messageId);
|
|
389
|
+
}
|
|
390
|
+
if (claimed && (!current || current !== expected)) {
|
|
391
|
+
return this.#discardResolvedInteractionReply(event, messageId);
|
|
392
|
+
}
|
|
393
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction, {
|
|
394
|
+
releaseMessageId: false,
|
|
395
|
+
finalize: false,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
399
|
+
await this.#state.markSeen(messageId);
|
|
400
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
401
|
+
this.#status.messagesReceived += 1;
|
|
402
|
+
|
|
403
|
+
const text = extractText(event);
|
|
404
|
+
if (!text) {
|
|
405
|
+
await this.#send(event.message.chat_id, '请用文字回答当前问题。');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const pending = this.#pendingInteractions.get(key);
|
|
410
|
+
if (!pending || pending !== expected || pending.submitting) {
|
|
411
|
+
if (this.#isResolvedQuestionReply(event, key)) {
|
|
412
|
+
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (claimed && (!pending || pending !== expected)) {
|
|
416
|
+
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction, {
|
|
420
|
+
releaseMessageId: false,
|
|
421
|
+
alreadyRecorded: true,
|
|
422
|
+
finalize: false,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
pending.chatId = event.message.chat_id;
|
|
426
|
+
if (pending.needsPresentation) {
|
|
427
|
+
try {
|
|
428
|
+
await this.#presentInteraction(pending);
|
|
429
|
+
} catch {
|
|
430
|
+
this.#status.lastError = '飞书交互问题发送失败。';
|
|
431
|
+
this.#logger.error?.('[dsh-feishu] failed to retry an interaction question');
|
|
432
|
+
pending.interaction.reconnect?.();
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const question = pending.questions[pending.index];
|
|
437
|
+
if (!question) return;
|
|
438
|
+
|
|
439
|
+
pending.answers.push(harnessAnswerForQuestion(question, text));
|
|
440
|
+
pending.index += 1;
|
|
441
|
+
if (pending.index < pending.questions.length) {
|
|
442
|
+
if (pending.claimedReplyMessageId === messageId) {
|
|
443
|
+
pending.claimedReplyMessageId = null;
|
|
444
|
+
}
|
|
445
|
+
pending.needsPresentation = true;
|
|
446
|
+
try {
|
|
447
|
+
await this.#presentInteraction(pending);
|
|
448
|
+
} catch {
|
|
449
|
+
this.#status.lastError = '飞书交互问题发送失败。';
|
|
450
|
+
this.#logger.error?.('[dsh-feishu] failed to send the next interaction question');
|
|
451
|
+
pending.interaction.reconnect?.();
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
pending.submitting = true;
|
|
457
|
+
try {
|
|
458
|
+
await pending.interaction.respond({
|
|
459
|
+
ok: true,
|
|
460
|
+
value: {
|
|
461
|
+
sessionId: pending.sessionId,
|
|
462
|
+
answer: { answers: pending.answers },
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
this.#rememberResolvedInteraction(key, pending);
|
|
466
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
467
|
+
this.#status.lastError = null;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (this.#signal?.aborted) return;
|
|
470
|
+
if (this.#pendingInteractions.get(key) !== pending) return;
|
|
471
|
+
if (error?.code === 'interaction-not-pending') {
|
|
472
|
+
this.#rememberResolvedInteraction(key, pending);
|
|
473
|
+
this.#clearPendingInteraction(key, pending.interactionId);
|
|
474
|
+
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
pending.submitting = false;
|
|
478
|
+
pending.answers.pop();
|
|
479
|
+
pending.index -= 1;
|
|
480
|
+
this.#status.lastError = '回答提交失败。';
|
|
481
|
+
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
|
|
482
|
+
await this.#send(event.message.chat_id, '回答提交失败,请重新发送当前问题的答案。')
|
|
483
|
+
.catch(() => undefined);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async #handleInteraction(interaction, {
|
|
488
|
+
key,
|
|
489
|
+
actor,
|
|
490
|
+
chatId,
|
|
491
|
+
requiresMention,
|
|
492
|
+
}) {
|
|
493
|
+
// Approval is deliberately exposed by the transport but remains
|
|
494
|
+
// unanswered until #5 adds an authenticated policy and renderer.
|
|
495
|
+
if (interaction?.kind !== 'question') return;
|
|
496
|
+
const questions = interaction?.payload?.questions;
|
|
497
|
+
const interactionId = typeof interaction?.interactionId === 'string'
|
|
498
|
+
? interaction.interactionId
|
|
499
|
+
: interaction?.rpcId;
|
|
500
|
+
if (typeof interaction.rpcId !== 'string'
|
|
501
|
+
|| typeof interactionId !== 'string'
|
|
502
|
+
|| typeof interaction.sessionId !== 'string'
|
|
503
|
+
|| !Array.isArray(questions)
|
|
504
|
+
|| questions.length === 0
|
|
505
|
+
|| questions.some((question) => !validHarnessQuestion(question))) {
|
|
506
|
+
this.#logger.warn?.('[dsh-feishu] ignored an invalid Harness question interaction');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (interaction.recovered === true) {
|
|
511
|
+
await interaction.respond({
|
|
512
|
+
ok: false,
|
|
513
|
+
error: {
|
|
514
|
+
code: 'cancelled',
|
|
515
|
+
message: 'Feishu safely cancelled an interaction left by an earlier client.',
|
|
516
|
+
details: {},
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
await this.#send(
|
|
520
|
+
chatId,
|
|
521
|
+
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
|
|
522
|
+
).catch(() => undefined);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const existing = this.#pendingInteractions.get(key);
|
|
527
|
+
if (existing?.interactionId === interactionId) {
|
|
528
|
+
existing.interaction = interaction;
|
|
529
|
+
if (existing.needsPresentation) await this.#presentInteraction(existing);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (this.#interactionKeys.has(interactionId)) return;
|
|
533
|
+
if (existing) {
|
|
534
|
+
await interaction.respond({
|
|
535
|
+
ok: false,
|
|
536
|
+
error: {
|
|
537
|
+
code: 'cancelled',
|
|
538
|
+
message: 'Feishu is already handling another user interaction.',
|
|
539
|
+
details: {},
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const pending = {
|
|
546
|
+
kind: 'question',
|
|
547
|
+
interactionId,
|
|
548
|
+
sessionId: interaction.sessionId,
|
|
549
|
+
interaction,
|
|
550
|
+
key,
|
|
551
|
+
actor,
|
|
552
|
+
requiresMention,
|
|
553
|
+
questions,
|
|
554
|
+
answers: [],
|
|
555
|
+
index: 0,
|
|
556
|
+
chatId,
|
|
557
|
+
queue: null,
|
|
558
|
+
claimedReplyMessageId: null,
|
|
559
|
+
submitting: false,
|
|
560
|
+
needsPresentation: true,
|
|
561
|
+
questionMessageIds: new Set(),
|
|
562
|
+
inactive: false,
|
|
563
|
+
};
|
|
564
|
+
this.#pendingInteractions.set(key, pending);
|
|
565
|
+
this.#interactionKeys.set(pending.interactionId, key);
|
|
566
|
+
await this.#presentInteraction(pending);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#handleInteractionResolved(resolution) {
|
|
570
|
+
const interactionId = resolution?.interactionId;
|
|
571
|
+
if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
|
|
572
|
+
const key = this.#interactionKeys.get(interactionId);
|
|
573
|
+
if (!key) return;
|
|
574
|
+
const pending = this.#pendingInteractions.get(key);
|
|
575
|
+
if (pending) this.#rememberResolvedInteraction(key, pending);
|
|
576
|
+
this.#clearPendingInteraction(key, interactionId);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async #presentInteraction(pending) {
|
|
580
|
+
const question = pending.questions[pending.index];
|
|
581
|
+
if (!question) return;
|
|
582
|
+
const messageId = await this.#send(
|
|
583
|
+
pending.chatId,
|
|
584
|
+
harnessQuestionText(
|
|
585
|
+
question,
|
|
586
|
+
pending.index,
|
|
587
|
+
pending.questions.length,
|
|
588
|
+
{ requiresMention: pending.requiresMention },
|
|
589
|
+
),
|
|
590
|
+
);
|
|
591
|
+
if (messageId) {
|
|
592
|
+
pending.questionMessageIds.add(messageId);
|
|
593
|
+
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
|
|
594
|
+
}
|
|
595
|
+
pending.needsPresentation = false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#rememberResolvedInteraction(key, pending) {
|
|
599
|
+
const expiresAt = Date.now() + RESOLVED_REPLY_TTL_MS;
|
|
600
|
+
for (const messageId of pending.questionMessageIds ?? []) {
|
|
601
|
+
this.#resolvedQuestionReplies.set(messageId, { key, expiresAt });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#isResolvedQuestionReply(event, key) {
|
|
606
|
+
const now = Date.now();
|
|
607
|
+
for (const [messageId, resolution] of this.#resolvedQuestionReplies) {
|
|
608
|
+
if (resolution.expiresAt <= now) this.#resolvedQuestionReplies.delete(messageId);
|
|
609
|
+
}
|
|
610
|
+
for (const reference of [event?.message?.parent_id, event?.message?.root_id]) {
|
|
611
|
+
const resolution = this.#resolvedQuestionReplies.get(reference);
|
|
612
|
+
if (resolution?.key === key && resolution.expiresAt > now) return true;
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async #discardResolvedInteractionReply(event, messageId) {
|
|
618
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
619
|
+
await this.#state.markSeen(messageId);
|
|
620
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
621
|
+
this.#status.messagesReceived += 1;
|
|
622
|
+
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#takePendingInteraction(key, interactionId) {
|
|
626
|
+
const pending = this.#pendingInteractions.get(key);
|
|
627
|
+
if (!pending
|
|
628
|
+
|| (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
|
|
629
|
+
this.#pendingInteractions.delete(key);
|
|
630
|
+
this.#interactionKeys.delete(pending.interactionId);
|
|
631
|
+
pending.inactive = true;
|
|
632
|
+
return pending;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#clearPendingInteraction(key, interactionId) {
|
|
636
|
+
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async #cancelPendingInteraction(key) {
|
|
640
|
+
const pending = this.#takePendingInteraction(key);
|
|
641
|
+
if (!pending || pending.kind !== 'question') return;
|
|
642
|
+
this.#rememberResolvedInteraction(key, pending);
|
|
643
|
+
try {
|
|
644
|
+
await pending.interaction.respond({
|
|
645
|
+
ok: false,
|
|
646
|
+
error: {
|
|
647
|
+
code: 'cancelled',
|
|
648
|
+
message: 'The Feishu interaction ended before the user answered.',
|
|
649
|
+
details: {},
|
|
650
|
+
},
|
|
651
|
+
}, { signal: AbortSignal.timeout(5_000) });
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
654
|
+
this.#logger.warn?.('[dsh-feishu] failed to cancel a pending Harness interaction');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
192
659
|
#progressText(update) {
|
|
193
660
|
if (update.type === 'text' && update.text) return update.text;
|
|
194
661
|
if (update.type === 'tool') {
|
|
@@ -206,12 +673,12 @@ export class FeishuHarnessBridge {
|
|
|
206
673
|
return reactionId;
|
|
207
674
|
} catch (error) {
|
|
208
675
|
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
|
209
|
-
|
|
676
|
+
this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
|
|
210
677
|
return null;
|
|
211
678
|
}
|
|
212
679
|
}
|
|
213
680
|
|
|
214
|
-
async #
|
|
681
|
+
async #removeProcessingReaction(messageId, processingReaction) {
|
|
215
682
|
const reactionId = await processingReaction;
|
|
216
683
|
if (reactionId && this.#channel?.removeReaction) {
|
|
217
684
|
try {
|
|
@@ -219,9 +686,13 @@ export class FeishuHarnessBridge {
|
|
|
219
686
|
this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
|
|
220
687
|
} catch (error) {
|
|
221
688
|
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
|
222
|
-
|
|
689
|
+
this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
|
|
223
690
|
}
|
|
224
691
|
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async #finishReaction(messageId, processingReaction, finalEmojiType) {
|
|
695
|
+
await this.#removeProcessingReaction(messageId, processingReaction);
|
|
225
696
|
await this.#addReaction(messageId, finalEmojiType);
|
|
226
697
|
}
|
|
227
698
|
|
|
@@ -237,5 +708,6 @@ export class FeishuHarnessBridge {
|
|
|
237
708
|
if (response?.code && response.code !== 0) {
|
|
238
709
|
throw new Error(`Feishu send failed: ${response.msg || response.code}`);
|
|
239
710
|
}
|
|
711
|
+
return nonEmptyString(response?.data?.message_id);
|
|
240
712
|
}
|
|
241
713
|
}
|