@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
|
@@ -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
|
'',
|
|
@@ -15,20 +23,55 @@ const HELP_TEXT = [
|
|
|
15
23
|
'/new 开启一个全新会话',
|
|
16
24
|
'/workspace 工作区绝对路径 切换工作区',
|
|
17
25
|
'/workspacelist 列出工作区绝对路径',
|
|
26
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
27
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
18
28
|
'/status 检查连接状态',
|
|
19
29
|
'/help 显示本帮助',
|
|
20
30
|
].join('\n');
|
|
21
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
|
+
|
|
22
59
|
export class FeishuHarnessBridge {
|
|
23
60
|
#client;
|
|
24
61
|
#channel;
|
|
25
62
|
#harness;
|
|
26
63
|
#state;
|
|
27
64
|
#queues = new Map();
|
|
65
|
+
#pendingInteractions = new Map();
|
|
66
|
+
#interactionKeys = new Map();
|
|
67
|
+
#resolvedQuestionReplies = new Map();
|
|
28
68
|
#acceptedMessageIds = new Set();
|
|
69
|
+
#interactionTasks = new Set();
|
|
29
70
|
#status;
|
|
30
71
|
#allowedSenderOpenIds;
|
|
31
72
|
#replyTimeoutMs;
|
|
73
|
+
#logger;
|
|
74
|
+
#signal;
|
|
32
75
|
|
|
33
76
|
constructor({
|
|
34
77
|
client,
|
|
@@ -37,8 +80,13 @@ export class FeishuHarnessBridge {
|
|
|
37
80
|
state,
|
|
38
81
|
status,
|
|
39
82
|
allowedSenderOpenIds = new Set(),
|
|
40
|
-
replyTimeoutMs =
|
|
83
|
+
replyTimeoutMs = 600_000,
|
|
84
|
+
logger = console,
|
|
85
|
+
signal,
|
|
41
86
|
}) {
|
|
87
|
+
if (!client || !harness || !state || !status) {
|
|
88
|
+
throw new TypeError('Feishu bridge dependencies are required');
|
|
89
|
+
}
|
|
42
90
|
this.#client = client;
|
|
43
91
|
this.#channel = channel;
|
|
44
92
|
this.#harness = harness;
|
|
@@ -46,55 +94,171 @@ export class FeishuHarnessBridge {
|
|
|
46
94
|
this.#status = status;
|
|
47
95
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
|
48
96
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
97
|
+
this.#logger = logger;
|
|
98
|
+
this.#signal = signal;
|
|
99
|
+
ensureStatus(this.#status);
|
|
49
100
|
}
|
|
50
101
|
|
|
51
102
|
accept(event) {
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
if (this.#signal?.aborted) return Promise.resolve();
|
|
104
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
105
|
+
if (!messageId || isBotSender(event)) return Promise.resolve();
|
|
54
106
|
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
|
|
55
107
|
this.#status.messagesRejected += 1;
|
|
56
108
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
57
|
-
|
|
58
|
-
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();
|
|
59
123
|
}
|
|
60
|
-
|
|
124
|
+
|
|
61
125
|
this.#acceptedMessageIds.add(messageId);
|
|
62
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
|
+
}
|
|
63
194
|
|
|
64
|
-
|
|
195
|
+
#enqueueMessage(event, messageId, key, processingReaction, {
|
|
196
|
+
releaseMessageId = true,
|
|
197
|
+
alreadyRecorded = false,
|
|
198
|
+
finalize = true,
|
|
199
|
+
} = {}) {
|
|
65
200
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
66
|
-
const
|
|
201
|
+
const work = previous
|
|
67
202
|
.catch(() => undefined)
|
|
68
|
-
.then(() => this.#handle(event, key))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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);
|
|
84
235
|
}
|
|
85
236
|
|
|
86
237
|
async waitForIdle() {
|
|
87
|
-
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
|
+
]);
|
|
88
245
|
}
|
|
89
246
|
|
|
90
|
-
async #handle(event, key) {
|
|
247
|
+
async #handle(event, key, { alreadyRecorded = false } = {}) {
|
|
248
|
+
this.#signal?.throwIfAborted();
|
|
91
249
|
const messageId = event.message.message_id;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
256
|
|
|
96
257
|
const text = extractText(event);
|
|
97
|
-
if (!text)
|
|
258
|
+
if (!text) {
|
|
259
|
+
await this.#send(event.message.chat_id, '目前仅支持文字消息。');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
98
262
|
|
|
99
263
|
if (text === '/help') {
|
|
100
264
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
|
@@ -106,11 +270,11 @@ export class FeishuHarnessBridge {
|
|
|
106
270
|
return;
|
|
107
271
|
}
|
|
108
272
|
if (text === '/status') {
|
|
109
|
-
await this.#harness.ensureRunning();
|
|
273
|
+
await this.#harness.ensureRunning({ signal: this.#signal });
|
|
110
274
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
|
111
275
|
return;
|
|
112
276
|
}
|
|
113
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
277
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
114
278
|
if (workspaceCommand) {
|
|
115
279
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
116
280
|
await this.#send(event.message.chat_id, reply);
|
|
@@ -118,11 +282,29 @@ export class FeishuHarnessBridge {
|
|
|
118
282
|
return;
|
|
119
283
|
}
|
|
120
284
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
};
|
|
126
308
|
}
|
|
127
309
|
|
|
128
310
|
async #answerWithStream(event, key, text) {
|
|
@@ -134,7 +316,9 @@ export class FeishuHarnessBridge {
|
|
|
134
316
|
state: this.#state,
|
|
135
317
|
key,
|
|
136
318
|
text,
|
|
137
|
-
|
|
319
|
+
createOptions: { signal: this.#signal },
|
|
320
|
+
existsOptions: { signal: this.#signal },
|
|
321
|
+
askOptions: this.#interactionAskOptions(event, key),
|
|
138
322
|
});
|
|
139
323
|
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
|
140
324
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
@@ -147,18 +331,21 @@ export class FeishuHarnessBridge {
|
|
|
147
331
|
await this.#channel.stream(chatId, {
|
|
148
332
|
markdown: async (controller) => {
|
|
149
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
|
+
};
|
|
150
341
|
({ answer: completedAnswer } = await askInWorkspaceSession({
|
|
151
342
|
harness: this.#harness,
|
|
152
343
|
state: this.#state,
|
|
153
344
|
key,
|
|
154
345
|
text,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
await controller.setContent(this.#progressText(update));
|
|
159
|
-
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
160
|
-
},
|
|
161
|
-
},
|
|
346
|
+
createOptions: { signal: this.#signal },
|
|
347
|
+
existsOptions: { signal: this.#signal },
|
|
348
|
+
askOptions,
|
|
162
349
|
}));
|
|
163
350
|
await controller.setContent(completedAnswer);
|
|
164
351
|
},
|
|
@@ -167,26 +354,308 @@ export class FeishuHarnessBridge {
|
|
|
167
354
|
} catch (error) {
|
|
168
355
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
169
356
|
if (completedAnswer) {
|
|
170
|
-
|
|
357
|
+
this.#logger.warn?.(
|
|
358
|
+
'[dsh-feishu] native stream failed after generation; sending final text:',
|
|
359
|
+
error.message,
|
|
360
|
+
);
|
|
171
361
|
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
|
|
172
362
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
173
363
|
return;
|
|
174
364
|
}
|
|
175
365
|
if (promptStarted) throw error;
|
|
176
366
|
|
|
177
|
-
|
|
367
|
+
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
|
178
368
|
const { answer } = await askInWorkspaceSession({
|
|
179
369
|
harness: this.#harness,
|
|
180
370
|
state: this.#state,
|
|
181
371
|
key,
|
|
182
372
|
text,
|
|
183
|
-
|
|
373
|
+
createOptions: { signal: this.#signal },
|
|
374
|
+
existsOptions: { signal: this.#signal },
|
|
375
|
+
askOptions: this.#interactionAskOptions(event, key),
|
|
184
376
|
});
|
|
185
377
|
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
|
186
378
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
187
379
|
}
|
|
188
380
|
}
|
|
189
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
|
+
|
|
190
659
|
#progressText(update) {
|
|
191
660
|
if (update.type === 'text' && update.text) return update.text;
|
|
192
661
|
if (update.type === 'tool') {
|
|
@@ -204,12 +673,12 @@ export class FeishuHarnessBridge {
|
|
|
204
673
|
return reactionId;
|
|
205
674
|
} catch (error) {
|
|
206
675
|
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
|
207
|
-
|
|
676
|
+
this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
|
|
208
677
|
return null;
|
|
209
678
|
}
|
|
210
679
|
}
|
|
211
680
|
|
|
212
|
-
async #
|
|
681
|
+
async #removeProcessingReaction(messageId, processingReaction) {
|
|
213
682
|
const reactionId = await processingReaction;
|
|
214
683
|
if (reactionId && this.#channel?.removeReaction) {
|
|
215
684
|
try {
|
|
@@ -217,9 +686,13 @@ export class FeishuHarnessBridge {
|
|
|
217
686
|
this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
|
|
218
687
|
} catch (error) {
|
|
219
688
|
this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
|
|
220
|
-
|
|
689
|
+
this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
|
|
221
690
|
}
|
|
222
691
|
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async #finishReaction(messageId, processingReaction, finalEmojiType) {
|
|
695
|
+
await this.#removeProcessingReaction(messageId, processingReaction);
|
|
223
696
|
await this.#addReaction(messageId, finalEmojiType);
|
|
224
697
|
}
|
|
225
698
|
|
|
@@ -235,5 +708,6 @@ export class FeishuHarnessBridge {
|
|
|
235
708
|
if (response?.code && response.code !== 0) {
|
|
236
709
|
throw new Error(`Feishu send failed: ${response.msg || response.code}`);
|
|
237
710
|
}
|
|
711
|
+
return nonEmptyString(response?.data?.message_id);
|
|
238
712
|
}
|
|
239
713
|
}
|