@xmanrui/dsh-im 0.10.0 → 0.12.0
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.en.md +36 -6
- package/README.md +36 -6
- package/lib/client.js +377 -27
- package/lib/index.js +145 -135
- package/package.json +31 -2
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +8 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +8 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +8 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +8 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +8 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +8 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +8 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +8 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/plugin-src/host/harness-session-coordinator.mjs +134 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +85 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +10 -0
- package/src/channels/feishu/bridge.mjs +87 -0
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +94 -7
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/qq/qq-bridge.mjs +91 -0
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +47 -1
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/control-command.mjs +84 -0
- package/src/channels/shared/harness-approval.mjs +4 -0
- package/src/channels/shared/harness-client.mjs +342 -35
- package/src/channels/shared/model-command.mjs +314 -0
- package/src/channels/shared/session-binding-lock.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +100 -0
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- package/src/channels/shared/workspace-session.mjs +21 -9
- package/src/channels/slack/slack-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +10 -0
- package/src/channels/telegram/telegram-runtime.mjs +10 -0
- package/src/channels/wecom/wecom-bridge.mjs +79 -0
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +85 -0
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +16 -0
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
maskDingtalkClientId,
|
|
7
7
|
maskDingtalkSenderId,
|
|
8
8
|
} from './config-store.mjs';
|
|
9
|
+
import {
|
|
10
|
+
connectionTestMessage,
|
|
11
|
+
connectionTestTargetUnavailable,
|
|
12
|
+
} from '../shared/connection-test.mjs';
|
|
9
13
|
|
|
10
14
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
11
15
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
|
@@ -386,6 +390,20 @@ export class DingtalkController {
|
|
|
386
390
|
return this.status();
|
|
387
391
|
}
|
|
388
392
|
|
|
393
|
+
async sendConnectionTest(botId) {
|
|
394
|
+
const config = this.#configStore.get(botId);
|
|
395
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
396
|
+
return this.#withBotTransition(botId, async () => {
|
|
397
|
+
const runtime = this.#runtimes.get(botId);
|
|
398
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
399
|
+
throw connectionTestTargetUnavailable('钉钉机器人');
|
|
400
|
+
}
|
|
401
|
+
return runtime.sendConnectionTest(connectionTestMessage(
|
|
402
|
+
`钉钉机器人(${maskDingtalkClientId(config.clientId)})`,
|
|
403
|
+
));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
389
407
|
/** Removes one bot, its secret, runtime, and local conversation state. */
|
|
390
408
|
async deleteBot(botId) {
|
|
391
409
|
const config = this.#configStore.get(botId);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createDingtalkBridgeStatus,
|
|
4
4
|
DingtalkHarnessBridge,
|
|
5
5
|
} from './dingtalk-bridge.mjs';
|
|
6
|
+
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
6
7
|
|
|
7
8
|
function nonEmptyString(value) {
|
|
8
9
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -347,6 +348,26 @@ export class DingtalkRuntime {
|
|
|
347
348
|
return this.status;
|
|
348
349
|
}
|
|
349
350
|
|
|
351
|
+
async sendConnectionTest(text) {
|
|
352
|
+
return sendRememberedConnectionTest({
|
|
353
|
+
state: this.#state,
|
|
354
|
+
text,
|
|
355
|
+
channelLabel: '钉钉机器人',
|
|
356
|
+
send: async ({ sessionWebhook }, content) => {
|
|
357
|
+
if (!this.#status.ready || !this.#abortController) {
|
|
358
|
+
throw new Error('DingTalk runtime is not connected');
|
|
359
|
+
}
|
|
360
|
+
await this.#api.sendText({
|
|
361
|
+
clientId: this.#config.clientId,
|
|
362
|
+
clientSecret: this.#clientSecret,
|
|
363
|
+
sessionWebhook,
|
|
364
|
+
text: content,
|
|
365
|
+
signal: this.#abortController.signal,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
350
371
|
#pendingSenders() {
|
|
351
372
|
return typeof this.#state.pendingSenders === 'function'
|
|
352
373
|
? this.#state.pendingSenders()
|
|
@@ -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.
|
|
111
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.12.0)',
|
|
112
112
|
},
|
|
113
113
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
114
114
|
signal: requestSignal(signal, timeoutMs),
|
|
@@ -108,6 +108,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
108
108
|
channelId: String(message.channel_id),
|
|
109
109
|
replyToMessageId: String(message.id),
|
|
110
110
|
},
|
|
111
|
+
connectionTestTarget: { channelId: String(message.channel_id) },
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -239,6 +240,15 @@ export class DiscordRuntime {
|
|
|
239
240
|
return structuredClone(this.#status);
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
async sendConnectionTest(text) {
|
|
244
|
+
if (!this.#status.ready || !this.#bridge) {
|
|
245
|
+
const error = new Error('Discord bot is not connected');
|
|
246
|
+
error.code = 'test-target-unavailable';
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
return this.#bridge.sendConnectionTest(text);
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
async start() {
|
|
243
253
|
if (this.#status.ready && this.#socket) return this.status;
|
|
244
254
|
if (this.#starting) return this.#starting;
|
|
@@ -18,6 +18,15 @@ import {
|
|
|
18
18
|
} from '../shared/harness-question.mjs';
|
|
19
19
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
20
20
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
21
|
+
import {
|
|
22
|
+
isControlCommand,
|
|
23
|
+
runControlCommand,
|
|
24
|
+
} from '../shared/control-command.mjs';
|
|
25
|
+
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
26
|
+
import {
|
|
27
|
+
isModelCommand,
|
|
28
|
+
runModelCommand,
|
|
29
|
+
} from '../shared/model-command.mjs';
|
|
21
30
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
22
31
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
23
32
|
|
|
@@ -34,6 +43,10 @@ const HELP_TEXT = [
|
|
|
34
43
|
'/workspacelist 列出工作区绝对路径',
|
|
35
44
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
36
45
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
46
|
+
'/models 列出所有可用模型',
|
|
47
|
+
'/model [模型ID] 查看或切换当前会话模型',
|
|
48
|
+
'/stop 停止当前任务',
|
|
49
|
+
'/steer 补充指令 纠偏当前任务',
|
|
37
50
|
'/status 检查连接状态',
|
|
38
51
|
'/help 显示本帮助',
|
|
39
52
|
].join('\n');
|
|
@@ -76,6 +89,7 @@ export class FeishuHarnessBridge {
|
|
|
76
89
|
#resolvedQuestionReplies = new Map();
|
|
77
90
|
#acceptedMessageIds = new Set();
|
|
78
91
|
#interactionTasks = new Set();
|
|
92
|
+
#commandTasks = new Set();
|
|
79
93
|
#approvals;
|
|
80
94
|
#status;
|
|
81
95
|
#allowedSenderOpenIds;
|
|
@@ -133,8 +147,44 @@ export class FeishuHarnessBridge {
|
|
|
133
147
|
return Promise.resolve();
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
if (event.message.chat_type === 'p2p') {
|
|
151
|
+
const chatId = nonEmptyString(event.message.chat_id);
|
|
152
|
+
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
|
153
|
+
}
|
|
154
|
+
|
|
136
155
|
this.#acceptedMessageIds.add(messageId);
|
|
137
156
|
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
|
157
|
+
const commandMessage = extractInboundMessage(event, this.#client);
|
|
158
|
+
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
159
|
+
const commandRunner = isControlCommand(commandText)
|
|
160
|
+
? runControlCommand
|
|
161
|
+
: (isModelCommand(commandText) ? runModelCommand : null);
|
|
162
|
+
const addressed = event?.message?.chat_type === 'p2p'
|
|
163
|
+
|| (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0);
|
|
164
|
+
if (commandRunner && addressed) {
|
|
165
|
+
const processing = this.#processFastCommand(
|
|
166
|
+
event,
|
|
167
|
+
messageId,
|
|
168
|
+
key,
|
|
169
|
+
commandMessage,
|
|
170
|
+
commandRunner,
|
|
171
|
+
);
|
|
172
|
+
let current;
|
|
173
|
+
current = processing
|
|
174
|
+
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
|
175
|
+
.catch((error) => this.#handleMessageFailure(
|
|
176
|
+
event,
|
|
177
|
+
messageId,
|
|
178
|
+
processingReaction,
|
|
179
|
+
error,
|
|
180
|
+
))
|
|
181
|
+
.finally(() => {
|
|
182
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
183
|
+
this.#commandTasks.delete(current);
|
|
184
|
+
});
|
|
185
|
+
this.#commandTasks.add(current);
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
138
188
|
if (this.#isResolvedQuestionReply(event, key)) {
|
|
139
189
|
const current = Promise.resolve()
|
|
140
190
|
.then(() => this.#discardResolvedInteractionReply(event, messageId))
|
|
@@ -270,6 +320,10 @@ export class FeishuHarnessBridge {
|
|
|
270
320
|
}
|
|
271
321
|
|
|
272
322
|
async #handleMessageFailure(event, messageId, processingReaction, error) {
|
|
323
|
+
if (error?.code === 'turn-stopped') {
|
|
324
|
+
await this.#removeProcessingReaction(messageId, processingReaction);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
273
327
|
if (this.#signal?.aborted) {
|
|
274
328
|
await this.#removeProcessingReaction(messageId, processingReaction);
|
|
275
329
|
return;
|
|
@@ -291,9 +345,41 @@ export class FeishuHarnessBridge {
|
|
|
291
345
|
pending.queue ? [pending.queue] : []
|
|
292
346
|
)),
|
|
293
347
|
...this.#interactionTasks,
|
|
348
|
+
...this.#commandTasks,
|
|
294
349
|
]);
|
|
295
350
|
}
|
|
296
351
|
|
|
352
|
+
async #processFastCommand(event, messageId, key, message, runner) {
|
|
353
|
+
this.#signal?.throwIfAborted();
|
|
354
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
355
|
+
await this.#state.markSeen(messageId);
|
|
356
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
357
|
+
this.#status.messagesReceived += 1;
|
|
358
|
+
const result = await runner(
|
|
359
|
+
nonEmptyString(message.content) ?? '',
|
|
360
|
+
this.#harness,
|
|
361
|
+
this.#state,
|
|
362
|
+
key,
|
|
363
|
+
{
|
|
364
|
+
signal: this.#signal,
|
|
365
|
+
hasImages: hasInboundImages(message),
|
|
366
|
+
pendingInteraction: this.#pendingInteractions.has(key)
|
|
367
|
+
|| this.#approvals.hasPending(key),
|
|
368
|
+
control: { owner: this, key },
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
if (result?.stopped) {
|
|
372
|
+
await Promise.allSettled([
|
|
373
|
+
this.#cancelPendingInteraction(key),
|
|
374
|
+
this.#approvals.closeRoute(key),
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
for (const reply of result?.messages ?? [result?.message]) {
|
|
378
|
+
if (reply) await this.#send(event.message.chat_id, reply);
|
|
379
|
+
}
|
|
380
|
+
this.#status.lastError = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
297
383
|
async #handle(event, key, { alreadyRecorded = false } = {}) {
|
|
298
384
|
this.#signal?.throwIfAborted();
|
|
299
385
|
const messageId = event.message.message_id;
|
|
@@ -366,6 +452,7 @@ export class FeishuHarnessBridge {
|
|
|
366
452
|
return {
|
|
367
453
|
timeoutMs: this.#replyTimeoutMs,
|
|
368
454
|
signal: this.#signal,
|
|
455
|
+
control: { owner: this, key },
|
|
369
456
|
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
|
370
457
|
key,
|
|
371
458
|
actor: senderOpenId(event),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
|
2
2
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
3
|
+
import {
|
|
4
|
+
connectionTestTargetUnavailable,
|
|
5
|
+
sendRememberedConnectionTest,
|
|
6
|
+
} from '../shared/connection-test.mjs';
|
|
3
7
|
|
|
4
8
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
5
9
|
|
|
@@ -240,6 +244,47 @@ export class FeishuRuntime {
|
|
|
240
244
|
}
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
async sendConnectionTest(text) {
|
|
248
|
+
if (!this.#status.ready || !this.#client) {
|
|
249
|
+
const error = new Error('飞书机器人尚未连接');
|
|
250
|
+
error.code = 'test-target-unavailable';
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
254
|
+
throw new TypeError('Feishu connection test text is required');
|
|
255
|
+
}
|
|
256
|
+
const send = async (receiveIdType, receiveId, content) => {
|
|
257
|
+
const response = await this.#client.im.v1.message.create({
|
|
258
|
+
params: { receive_id_type: receiveIdType },
|
|
259
|
+
data: {
|
|
260
|
+
receive_id: receiveId,
|
|
261
|
+
msg_type: 'text',
|
|
262
|
+
content: JSON.stringify({ text: content }),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (response?.code && response.code !== 0) {
|
|
266
|
+
throw new Error(`Feishu connection test failed: ${response.msg || response.code}`);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const ownerOpenId = this.#ownerOpenIds.find((value) => value !== '*');
|
|
271
|
+
if (ownerOpenId) {
|
|
272
|
+
await send('open_id', ownerOpenId, text);
|
|
273
|
+
return { sent: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return sendRememberedConnectionTest({
|
|
277
|
+
state: this.#state,
|
|
278
|
+
text,
|
|
279
|
+
channelLabel: '飞书机器人',
|
|
280
|
+
send: async (target, content) => {
|
|
281
|
+
const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
|
|
282
|
+
if (!chatId) throw connectionTestTargetUnavailable('飞书机器人');
|
|
283
|
+
await send('chat_id', chatId, content);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
243
288
|
async stop({ preserveError = false } = {}) {
|
|
244
289
|
const error = preserveError ? this.#status.lastError : null;
|
|
245
290
|
const abortController = this.#abortController;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
2
2
|
|
|
3
|
+
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
4
|
+
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
5
|
+
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
6
|
+
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
7
|
+
'飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。';
|
|
8
|
+
|
|
3
9
|
export function conversationKey(event) {
|
|
4
10
|
const chatType = event?.message?.chat_type;
|
|
5
11
|
if (chatType === 'p2p') {
|
|
@@ -119,17 +125,98 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
function providerCode(value) {
|
|
129
|
+
if (!value || typeof value !== 'object') return null;
|
|
130
|
+
const code = value.code ?? value.error?.code;
|
|
131
|
+
return Number.isSafeInteger(Number(code)) ? Number(code) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readFeishuErrorBody(stream, signal) {
|
|
135
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') return null;
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
const timeout = AbortSignal.timeout(FEISHU_ERROR_BODY_TIMEOUT_MS);
|
|
138
|
+
const readSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
139
|
+
const abort = () => stream.destroy?.(readSignal.reason);
|
|
140
|
+
readSignal.addEventListener('abort', abort, { once: true });
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let size = 0;
|
|
143
|
+
try {
|
|
144
|
+
for await (const chunk of stream) {
|
|
145
|
+
const data = Buffer.from(chunk);
|
|
146
|
+
size += data.length;
|
|
147
|
+
if (size > FEISHU_ERROR_BODY_LIMIT) {
|
|
148
|
+
stream.destroy?.();
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
chunks.push(data);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.concat(chunks, size).toString('utf8');
|
|
154
|
+
} catch {
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
return null;
|
|
157
|
+
} finally {
|
|
158
|
+
readSignal.removeEventListener('abort', abort);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function feishuProviderCode(error, signal) {
|
|
163
|
+
const pending = [error];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
while (pending.length > 0 && seen.size < 8) {
|
|
166
|
+
const value = pending.shift();
|
|
167
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
seen.add(value);
|
|
171
|
+
const directCode = providerCode(value);
|
|
172
|
+
const data = value.response?.data ?? value.data;
|
|
173
|
+
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
174
|
+
data?.destroy?.();
|
|
175
|
+
return directCode;
|
|
176
|
+
}
|
|
177
|
+
if (data && typeof data[Symbol.asyncIterator] === 'function') {
|
|
178
|
+
const body = await readFeishuErrorBody(data, signal);
|
|
179
|
+
try {
|
|
180
|
+
const parsedCode = providerCode(JSON.parse(body));
|
|
181
|
+
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
|
|
182
|
+
} catch {
|
|
183
|
+
// Non-JSON provider failures keep the generic image download message.
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
const dataCode = providerCode(data);
|
|
187
|
+
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
|
|
188
|
+
}
|
|
189
|
+
pending.push(value.cause);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function feishuImageDownloadError(error, signal) {
|
|
195
|
+
if (await feishuProviderCode(error, signal) !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
|
|
196
|
+
return new ImagePromptError(
|
|
197
|
+
'feishu-image-permission-required',
|
|
198
|
+
'Feishu image download requires the im:message:readonly tenant scope',
|
|
199
|
+
FEISHU_IMAGE_PERMISSION_MESSAGE,
|
|
200
|
+
{ cause: error },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
122
204
|
function feishuImageSource(event, client, key) {
|
|
123
205
|
return {
|
|
124
206
|
async load({ signal, maxBytes }) {
|
|
125
207
|
signal?.throwIfAborted();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
208
|
+
let resource;
|
|
209
|
+
try {
|
|
210
|
+
resource = await client?.im?.v1?.messageResource?.get?.({
|
|
211
|
+
path: {
|
|
212
|
+
message_id: event.message.message_id,
|
|
213
|
+
file_key: key,
|
|
214
|
+
},
|
|
215
|
+
params: { type: 'image' },
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw await feishuImageDownloadError(error, signal);
|
|
219
|
+
}
|
|
133
220
|
signal?.throwIfAborted();
|
|
134
221
|
const size = declaredSize(resource?.headers);
|
|
135
222
|
if (size !== null && size > maxBytes) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
2
3
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
3
4
|
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
|
4
5
|
|
|
@@ -339,6 +340,26 @@ export class MultiBotDshFeishuController {
|
|
|
339
340
|
});
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
async sendConnectionTest(botId) {
|
|
344
|
+
this.#assertOpen();
|
|
345
|
+
return this.#withBotTransition(botId, async () => {
|
|
346
|
+
const config = this.#requireBot(botId);
|
|
347
|
+
const runtime = this.#runtimes.get(botId);
|
|
348
|
+
if (!isConnected(connectionStatus(runtime))
|
|
349
|
+
|| typeof runtime.sendConnectionTest !== 'function') {
|
|
350
|
+
const error = new Error('飞书机器人尚未连接');
|
|
351
|
+
error.code = 'test-target-unavailable';
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
return runtime.sendConnectionTest(
|
|
355
|
+
connectionTestMessage(
|
|
356
|
+
`${config.botName}(${maskedAppId(config.appId)})`,
|
|
357
|
+
'飞书机器人',
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
342
363
|
async disconnectBot(botId) {
|
|
343
364
|
this.#assertOpen();
|
|
344
365
|
// An operational pause only: credentials/config remain durable, so the
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
2
2
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
3
|
+
import {
|
|
4
|
+
isControlCommand,
|
|
5
|
+
runControlCommand,
|
|
6
|
+
} from '../shared/control-command.mjs';
|
|
7
|
+
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
3
8
|
import {
|
|
4
9
|
harnessAnswerForQuestion,
|
|
5
10
|
harnessQuestionText,
|
|
6
11
|
validHarnessQuestion,
|
|
7
12
|
} from '../shared/harness-question.mjs';
|
|
8
13
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
14
|
+
import {
|
|
15
|
+
isModelCommand,
|
|
16
|
+
runModelCommand,
|
|
17
|
+
} from '../shared/model-command.mjs';
|
|
9
18
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
10
19
|
import {
|
|
11
20
|
fetchImageBuffer,
|
|
@@ -37,6 +46,10 @@ const HELP_TEXT = [
|
|
|
37
46
|
'/workspacelist 列出工作区绝对路径',
|
|
38
47
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
39
48
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
49
|
+
'/models 列出所有可用模型',
|
|
50
|
+
'/model [模型ID] 查看或切换当前会话模型',
|
|
51
|
+
'/stop 停止当前任务',
|
|
52
|
+
'/steer 补充指令 纠偏当前任务',
|
|
40
53
|
'/status 检查连接状态',
|
|
41
54
|
'/help 显示本帮助',
|
|
42
55
|
].join('\n');
|
|
@@ -133,6 +146,7 @@ export class QqHarnessBridge {
|
|
|
133
146
|
#interactionKeys = new Map();
|
|
134
147
|
#acceptedMessageIds = new Set();
|
|
135
148
|
#approvalTasks = new Set();
|
|
149
|
+
#commandTasks = new Set();
|
|
136
150
|
#approvals;
|
|
137
151
|
|
|
138
152
|
constructor({
|
|
@@ -176,7 +190,41 @@ export class QqHarnessBridge {
|
|
|
176
190
|
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
|
177
191
|
const key = conversationKey(message);
|
|
178
192
|
this.#acceptedMessageIds.add(messageId);
|
|
193
|
+
if (message.kind === 'c2c'
|
|
194
|
+
&& (this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid)
|
|
195
|
+
&& message.replyTarget?.scope === 'c2c'
|
|
196
|
+
&& nonEmptyString(message.replyTarget.targetId) === sender) {
|
|
197
|
+
rememberConnectionTestTarget(this.#state, message.replyTarget);
|
|
198
|
+
}
|
|
179
199
|
const pending = this.#pendingInteractions.get(key);
|
|
200
|
+
const commandText = safeText(message);
|
|
201
|
+
const commandRunner = isControlCommand(commandText)
|
|
202
|
+
? runControlCommand
|
|
203
|
+
: (isModelCommand(commandText) ? runModelCommand : null);
|
|
204
|
+
const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
|
|
205
|
+
const addressed = message.kind !== 'group'
|
|
206
|
+
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
|
207
|
+
if (commandRunner && allowed && addressed) {
|
|
208
|
+
let task;
|
|
209
|
+
task = this.#processFastCommand(
|
|
210
|
+
message,
|
|
211
|
+
messageId,
|
|
212
|
+
key,
|
|
213
|
+
commandText,
|
|
214
|
+
commandRunner,
|
|
215
|
+
).catch((error) => {
|
|
216
|
+
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
|
217
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
218
|
+
this.#logger.error?.('[dsh-im:qq] failed to process a command:', error);
|
|
219
|
+
return this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
|
|
220
|
+
.catch(() => undefined);
|
|
221
|
+
}).finally(() => {
|
|
222
|
+
this.#acceptedMessageIds.delete(messageId);
|
|
223
|
+
this.#commandTasks.delete(task);
|
|
224
|
+
});
|
|
225
|
+
this.#commandTasks.add(task);
|
|
226
|
+
return task;
|
|
227
|
+
}
|
|
180
228
|
const approval = this.#approvals.claimReply({
|
|
181
229
|
key,
|
|
182
230
|
actor: sender,
|
|
@@ -255,9 +303,35 @@ export class QqHarnessBridge {
|
|
|
255
303
|
pending.queue ? [pending.queue] : []
|
|
256
304
|
)),
|
|
257
305
|
...this.#approvalTasks,
|
|
306
|
+
...this.#commandTasks,
|
|
258
307
|
]);
|
|
259
308
|
}
|
|
260
309
|
|
|
310
|
+
async #processFastCommand(message, messageId, key, text, runner) {
|
|
311
|
+
this.#signal?.throwIfAborted();
|
|
312
|
+
if (this.#state.hasSeen(messageId)) return;
|
|
313
|
+
await this.#state.markSeen(messageId);
|
|
314
|
+
this.#status.messagesReceived += 1;
|
|
315
|
+
this.#status.lastMessageAt = new Date().toISOString();
|
|
316
|
+
const result = await runner(text, this.#harness, this.#state, key, {
|
|
317
|
+
signal: this.#signal,
|
|
318
|
+
hasImages: hasQqImageAttachments(message),
|
|
319
|
+
pendingInteraction: this.#pendingInteractions.has(key)
|
|
320
|
+
|| this.#approvals.hasPending(key),
|
|
321
|
+
control: { owner: this, key },
|
|
322
|
+
});
|
|
323
|
+
if (result?.stopped) {
|
|
324
|
+
await Promise.allSettled([
|
|
325
|
+
this.#cancelPendingInteraction(key),
|
|
326
|
+
this.#approvals.closeRoute(key),
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
for (const reply of result?.messages ?? [result?.message]) {
|
|
330
|
+
if (reply) await this.#bot.sendText(message.replyTarget, reply);
|
|
331
|
+
}
|
|
332
|
+
this.#status.lastError = null;
|
|
333
|
+
}
|
|
334
|
+
|
|
261
335
|
async #process(message, key, { alreadyRecorded = false } = {}) {
|
|
262
336
|
if (this.#signal?.aborted) return;
|
|
263
337
|
const messageId = nonEmptyString(message?.messageId);
|
|
@@ -353,6 +427,7 @@ export class QqHarnessBridge {
|
|
|
353
427
|
askOptions: {
|
|
354
428
|
timeoutMs: this.#replyTimeoutMs,
|
|
355
429
|
signal: this.#signal,
|
|
430
|
+
control: { owner: this, key },
|
|
356
431
|
onUpdate: stream ? async (update) => {
|
|
357
432
|
const progress = update.type === 'text'
|
|
358
433
|
? update.text
|
|
@@ -392,6 +467,22 @@ export class QqHarnessBridge {
|
|
|
392
467
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
393
468
|
this.#status.lastError = null;
|
|
394
469
|
} catch (error) {
|
|
470
|
+
if (error?.code === 'turn-stopped') {
|
|
471
|
+
if (stream) {
|
|
472
|
+
try {
|
|
473
|
+
await stream.cancel?.();
|
|
474
|
+
} catch (streamError) {
|
|
475
|
+
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
await this.#bot.sendText(target, '已停止。');
|
|
479
|
+
} catch (sendError) {
|
|
480
|
+
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
await this.#state.markSeen(messageId);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
395
486
|
stream?.cancel?.();
|
|
396
487
|
if (this.#signal?.aborted) return;
|
|
397
488
|
this.#status.lastError = error?.message ?? String(error);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
3
4
|
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
|
4
5
|
|
|
5
6
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
|
@@ -248,6 +249,23 @@ export class QqController {
|
|
|
248
249
|
return this.status();
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
async sendConnectionTest(botId) {
|
|
253
|
+
const config = this.#configStore.get(botId);
|
|
254
|
+
if (!config) throw new Error('Unknown QQ bot');
|
|
255
|
+
return this.#withBotTransition(botId, async () => {
|
|
256
|
+
const runtime = this.#runtimes.get(botId);
|
|
257
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
258
|
+
const error = new Error('QQ机器人尚未连接');
|
|
259
|
+
error.code = 'test-target-unavailable';
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
await runtime.sendConnectionTest(connectionTestMessage(
|
|
263
|
+
`QQ 机器人(${maskQqAppId(config.appId)})`,
|
|
264
|
+
));
|
|
265
|
+
return { sent: true };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
async deleteBot(botId) {
|
|
252
270
|
const config = this.#configStore.get(botId);
|
|
253
271
|
if (!config) throw new Error('Unknown QQ bot');
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
connectionTestTarget,
|
|
5
|
+
connectionTestTargetUnavailable,
|
|
6
|
+
} from '../shared/connection-test.mjs';
|
|
3
7
|
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
|
4
8
|
|
|
5
9
|
function timeoutError() {
|
|
@@ -67,6 +71,28 @@ export class QqRuntime {
|
|
|
67
71
|
return structuredClone(this.#status);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
async sendConnectionTest(text) {
|
|
75
|
+
if (!this.#status.ready || !this.#bot) {
|
|
76
|
+
throw connectionTestTargetUnavailable('QQ机器人');
|
|
77
|
+
}
|
|
78
|
+
const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string'
|
|
79
|
+
? this.#config.ownerUserOpenid.trim()
|
|
80
|
+
: '';
|
|
81
|
+
const remembered = connectionTestTarget(this.#state);
|
|
82
|
+
const rememberedUserOpenid = remembered?.scope === 'c2c'
|
|
83
|
+
&& typeof remembered.targetId === 'string'
|
|
84
|
+
? remembered.targetId.trim()
|
|
85
|
+
: '';
|
|
86
|
+
const target = rememberedUserOpenid
|
|
87
|
+
? { scope: 'c2c', targetId: rememberedUserOpenid }
|
|
88
|
+
: (ownerUserOpenid && ownerUserOpenid !== '*'
|
|
89
|
+
? { scope: 'c2c', targetId: ownerUserOpenid }
|
|
90
|
+
: null);
|
|
91
|
+
if (!target) throw connectionTestTargetUnavailable('QQ机器人');
|
|
92
|
+
await this.#bot.sendText(target, text);
|
|
93
|
+
return { sent: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
async start() {
|
|
71
97
|
if (this.#status.ready && this.#bot) return this.status;
|
|
72
98
|
if (this.#starting) return this.#starting;
|