@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.
Files changed (68) hide show
  1. package/README.en.md +36 -6
  2. package/README.md +36 -6
  3. package/lib/client.js +377 -27
  4. package/lib/index.js +145 -135
  5. package/package.json +31 -2
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +8 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +8 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +8 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +8 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +8 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +8 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +8 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +8 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/plugin-src/host/harness-session-coordinator.mjs +134 -0
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +85 -0
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +10 -0
  40. package/src/channels/feishu/bridge.mjs +87 -0
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +94 -7
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/qq/qq-bridge.mjs +91 -0
  45. package/src/channels/qq/qq-controller.mjs +18 -0
  46. package/src/channels/qq/qq-runtime.mjs +26 -0
  47. package/src/channels/shared/bot-workspace-store.mjs +47 -1
  48. package/src/channels/shared/connection-test.mjs +54 -0
  49. package/src/channels/shared/control-command.mjs +84 -0
  50. package/src/channels/shared/harness-approval.mjs +4 -0
  51. package/src/channels/shared/harness-client.mjs +342 -35
  52. package/src/channels/shared/model-command.mjs +314 -0
  53. package/src/channels/shared/session-binding-lock.mjs +40 -0
  54. package/src/channels/shared/text-harness-bridge.mjs +100 -0
  55. package/src/channels/shared/token-bot-controller.mjs +21 -0
  56. package/src/channels/shared/workspace-session.mjs +21 -9
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +10 -0
  59. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  60. package/src/channels/wecom/wecom-bridge.mjs +79 -0
  61. package/src/channels/wecom/wecom-controller.mjs +18 -0
  62. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  63. package/src/channels/weixin/weixin-api.mjs +1 -1
  64. package/src/channels/weixin/weixin-bridge.mjs +85 -0
  65. package/src/channels/weixin/weixin-controller.mjs +18 -0
  66. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  67. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  68. 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.10.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
- const resource = await client?.im?.v1?.messageResource?.get?.({
127
- path: {
128
- message_id: event.message.message_id,
129
- file_key: key,
130
- },
131
- params: { type: 'image' },
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;