@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,14 @@ import {
6
6
  } from '../shared/harness-question.mjs';
7
7
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
8
8
  import { runCompactCommand } from '../shared/compact-command.mjs';
9
+ import {
10
+ isControlCommand,
11
+ runControlCommand,
12
+ } from '../shared/control-command.mjs';
13
+ import {
14
+ isModelCommand,
15
+ runModelCommand,
16
+ } from '../shared/model-command.mjs';
9
17
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
10
18
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
11
19
  import {
@@ -14,6 +22,7 @@ import {
14
22
  imagePromptUserMessage,
15
23
  promptContentForMessage,
16
24
  } from '../shared/image-prompt.mjs';
25
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
17
26
 
18
27
  const HELP_TEXT = [
19
28
  '企业微信机器人已连接 DeepSeek Harness。',
@@ -25,6 +34,10 @@ const HELP_TEXT = [
25
34
  '/workspacelist 列出工作区绝对路径',
26
35
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
27
36
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
37
+ '/models 列出所有可用模型',
38
+ '/model [模型ID] 查看或切换当前会话模型',
39
+ '/stop 停止当前任务',
40
+ '/steer 补充指令 纠偏当前任务',
28
41
  '/status 检查连接状态',
29
42
  '/help 显示本帮助',
30
43
  ].join('\n');
@@ -222,6 +235,7 @@ export class WecomHarnessBridge {
222
235
  #interactionKeys = new Map();
223
236
  #acceptedMessageIds = new Set();
224
237
  #approvalTasks = new Set();
238
+ #commandTasks = new Set();
225
239
  #approvals;
226
240
  #prefetchedImageCount = 0;
227
241
 
@@ -269,7 +283,37 @@ export class WecomHarnessBridge {
269
283
 
270
284
  const key = conversationKey(frame);
271
285
  this.#acceptedMessageIds.add(messageId);
286
+ if (body.chattype === 'single') {
287
+ rememberConnectionTestTarget(this.#state, { chatId });
288
+ }
272
289
  const pending = this.#pendingInteractions.get(key);
290
+ const commandMessage = wecomInboundMessage(frame, this.#client);
291
+ const commandText = nonEmptyString(commandMessage.content) ?? '';
292
+ const commandRunner = isControlCommand(commandText)
293
+ ? runControlCommand
294
+ : (isModelCommand(commandText) ? runModelCommand : null);
295
+ if (commandRunner) {
296
+ let task;
297
+ task = this.#processFastCommand(
298
+ frame,
299
+ messageId,
300
+ chatId,
301
+ key,
302
+ commandMessage,
303
+ commandRunner,
304
+ ).catch((error) => {
305
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
306
+ this.#status.lastError = error?.message ?? String(error);
307
+ this.#logger.error?.('[dsh-im:wecom] failed to process a command');
308
+ return this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。')
309
+ .catch(() => undefined);
310
+ }).finally(() => {
311
+ this.#acceptedMessageIds.delete(messageId);
312
+ this.#commandTasks.delete(task);
313
+ });
314
+ this.#commandTasks.add(task);
315
+ return task;
316
+ }
273
317
  const approval = this.#approvals.claimReply({
274
318
  key,
275
319
  actor: senderId,
@@ -373,9 +417,35 @@ export class WecomHarnessBridge {
373
417
  pending.queue ? [pending.queue] : []
374
418
  )),
375
419
  ...this.#approvalTasks,
420
+ ...this.#commandTasks,
376
421
  ]);
377
422
  }
378
423
 
424
+ async #processFastCommand(frame, messageId, chatId, key, message, runner) {
425
+ this.#signal?.throwIfAborted();
426
+ if (this.#state.hasSeen(messageId)) return;
427
+ await this.#state.markSeen(messageId);
428
+ this.#status.messagesReceived += 1;
429
+ this.#status.lastMessageAt = new Date().toISOString();
430
+ const result = await runner(message.content, this.#harness, this.#state, key, {
431
+ signal: this.#signal,
432
+ hasImages: hasInboundImages(message),
433
+ pendingInteraction: this.#pendingInteractions.has(key)
434
+ || this.#approvals.hasPending(key),
435
+ control: { owner: this, key },
436
+ });
437
+ if (result?.stopped) {
438
+ await Promise.allSettled([
439
+ this.#cancelPendingInteraction(key),
440
+ this.#approvals.closeRoute(key),
441
+ ]);
442
+ }
443
+ for (const reply of result?.messages ?? [result?.message]) {
444
+ if (reply) await this.#sendImmediate(frame, chatId, reply);
445
+ }
446
+ this.#status.lastError = null;
447
+ }
448
+
379
449
  async #sendActive(chatId, text) {
380
450
  for (const chunk of splitUtf8(text)) {
381
451
  this.#signal?.throwIfAborted();
@@ -486,6 +556,7 @@ export class WecomHarnessBridge {
486
556
  askOptions: {
487
557
  timeoutMs: this.#replyTimeoutMs,
488
558
  signal: this.#signal,
559
+ control: { owner: this, key },
489
560
  onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
490
561
  ? async (update) => {
491
562
  const progress = splitUtf8(progressText(update))[0];
@@ -521,6 +592,14 @@ export class WecomHarnessBridge {
521
592
  this.#status.lastReplyAt = new Date().toISOString();
522
593
  this.#status.lastError = null;
523
594
  } catch (error) {
595
+ if (error?.code === 'turn-stopped') {
596
+ if (streamStarted && streamId) {
597
+ await this.#client.replyStream(frame, streamId, '已停止。', true)
598
+ .catch(() => undefined);
599
+ }
600
+ await this.#state.markSeen(messageId);
601
+ return;
602
+ }
524
603
  if (this.#signal?.aborted) return;
525
604
  this.#status.lastError = error?.message ?? String(error);
526
605
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs';
4
+ import {
5
+ connectionTestMessage,
6
+ connectionTestTargetUnavailable,
7
+ } from '../shared/connection-test.mjs';
4
8
 
5
9
  const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
6
10
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
@@ -237,6 +241,20 @@ export class WecomController {
237
241
  return this.status();
238
242
  }
239
243
 
244
+ async sendConnectionTest(botId) {
245
+ const config = this.#configStore.get(botId);
246
+ if (!config) throw new Error('Unknown Enterprise WeChat bot');
247
+ return this.#withBotTransition(botId, async () => {
248
+ const runtime = this.#runtimes.get(botId);
249
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
250
+ throw connectionTestTargetUnavailable('企业微信机器人');
251
+ }
252
+ return runtime.sendConnectionTest(connectionTestMessage(
253
+ `企业微信机器人(${maskWecomBotId(config.remoteBotId)})`,
254
+ ));
255
+ });
256
+ }
257
+
240
258
  async deleteBot(botId) {
241
259
  const config = this.#configStore.get(botId);
242
260
  if (!config) throw new Error('Unknown Enterprise WeChat bot');
@@ -1,6 +1,7 @@
1
1
  import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
2
2
 
3
3
  import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
4
+ import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
4
5
 
5
6
  function timeoutError() {
6
7
  const error = new Error('Enterprise WeChat WebSocket authentication timed out');
@@ -197,6 +198,23 @@ export class WecomRuntime {
197
198
  return this.status;
198
199
  }
199
200
 
201
+ async sendConnectionTest(text) {
202
+ return sendRememberedConnectionTest({
203
+ state: this.#state,
204
+ text,
205
+ channelLabel: '企业微信机器人',
206
+ send: async ({ chatId }, content) => {
207
+ if (!this.#status.ready || !this.#client) {
208
+ throw new Error('Enterprise WeChat runtime is not connected');
209
+ }
210
+ await this.#client.sendMessage(chatId, {
211
+ msgtype: 'markdown',
212
+ markdown: { content },
213
+ });
214
+ },
215
+ });
216
+ }
217
+
200
218
  async #stopActive() {
201
219
  const client = this.#client;
202
220
  const bridge = this.#bridge;
@@ -184,7 +184,7 @@ function authenticatedHeaders(token) {
184
184
  function baseInfo() {
185
185
  return {
186
186
  channel_version: WEIXIN_PROTOCOL_VERSION,
187
- bot_agent: 'DeepSeekHarness/0.10.0',
187
+ bot_agent: 'DeepSeekHarness/0.12.0',
188
188
  };
189
189
  }
190
190
 
@@ -11,6 +11,14 @@ import {
11
11
  } from '../shared/harness-question.mjs';
12
12
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
13
13
  import { runCompactCommand } from '../shared/compact-command.mjs';
14
+ import {
15
+ isControlCommand,
16
+ runControlCommand,
17
+ } from '../shared/control-command.mjs';
18
+ import {
19
+ isModelCommand,
20
+ runModelCommand,
21
+ } from '../shared/model-command.mjs';
14
22
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
15
23
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
16
24
  import {
@@ -18,6 +26,7 @@ import {
18
26
  imagePromptUserMessage,
19
27
  promptContentForMessage,
20
28
  } from '../shared/image-prompt.mjs';
29
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
21
30
 
22
31
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
23
32
 
@@ -31,6 +40,10 @@ const HELP_TEXT = [
31
40
  '/workspacelist 列出工作区绝对路径',
32
41
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
33
42
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
43
+ '/models 列出所有可用模型',
44
+ '/model [模型ID] 查看或切换当前会话模型',
45
+ '/stop 停止当前任务',
46
+ '/steer 补充指令 纠偏当前任务',
34
47
  '/status 检查连接状态',
35
48
  '/help 显示本帮助',
36
49
  ].join('\n');
@@ -84,6 +97,7 @@ export class WeixinHarnessBridge {
84
97
  #interactionKeys = new Map();
85
98
  #acceptedMessageIds = new Set();
86
99
  #approvalTasks = new Set();
100
+ #commandTasks = new Set();
87
101
  #approvals;
88
102
 
89
103
  constructor({
@@ -128,10 +142,41 @@ export class WeixinHarnessBridge {
128
142
  if (!messageId || !sender || this.#state.hasSeen(messageId)
129
143
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
130
144
  this.#acceptedMessageIds.add(messageId);
145
+ if (sender === this.#ownerUserId) {
146
+ rememberConnectionTestTarget(this.#state, { toUserId: sender });
147
+ }
131
148
  const key = conversationKey(sender);
132
149
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
133
150
  const runId = nonEmptyString(message?.run_id) ?? undefined;
134
151
  const pending = this.#pendingInteractions.get(key);
152
+ const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
153
+ const commandRunner = isControlCommand(commandText)
154
+ ? runControlCommand
155
+ : (isModelCommand(commandText) ? runModelCommand : null);
156
+ if (commandRunner && sender === this.#ownerUserId) {
157
+ let task;
158
+ task = this.#processFastCommand(
159
+ message,
160
+ messageId,
161
+ key,
162
+ sender,
163
+ contextToken,
164
+ runId,
165
+ commandText,
166
+ commandRunner,
167
+ ).catch((error) => {
168
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
169
+ this.#status.lastError = error?.message ?? String(error);
170
+ this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
171
+ return this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId)
172
+ .catch(() => undefined);
173
+ }).finally(() => {
174
+ this.#acceptedMessageIds.delete(messageId);
175
+ this.#commandTasks.delete(task);
176
+ });
177
+ this.#commandTasks.add(task);
178
+ return task;
179
+ }
135
180
  const approval = this.#approvals.claimReply({
136
181
  key,
137
182
  actor: sender,
@@ -207,9 +252,44 @@ export class WeixinHarnessBridge {
207
252
  pending.queue ? [pending.queue] : []
208
253
  )),
209
254
  ...this.#approvalTasks,
255
+ ...this.#commandTasks,
210
256
  ]);
211
257
  }
212
258
 
259
+ async #processFastCommand(
260
+ message,
261
+ messageId,
262
+ key,
263
+ sender,
264
+ contextToken,
265
+ runId,
266
+ text,
267
+ runner,
268
+ ) {
269
+ this.#signal?.throwIfAborted();
270
+ if (this.#state.hasSeen(messageId)) return;
271
+ await this.#state.markSeen(messageId);
272
+ this.#status.messagesReceived += 1;
273
+ this.#status.lastMessageAt = new Date().toISOString();
274
+ const result = await runner(text, this.#harness, this.#state, key, {
275
+ signal: this.#signal,
276
+ hasImages: hasWeixinImageItems(message),
277
+ pendingInteraction: this.#pendingInteractions.has(key)
278
+ || this.#approvals.hasPending(key),
279
+ control: { owner: this, key },
280
+ });
281
+ if (result?.stopped) {
282
+ await Promise.allSettled([
283
+ this.#cancelPendingInteraction(key),
284
+ this.#approvals.closeRoute(key),
285
+ ]);
286
+ }
287
+ for (const reply of result?.messages ?? [result?.message]) {
288
+ if (reply) await this.#send(sender, reply, contextToken, runId);
289
+ }
290
+ this.#status.lastError = null;
291
+ }
292
+
213
293
  async #process(message, key, { alreadyRecorded = false } = {}) {
214
294
  this.#signal?.throwIfAborted();
215
295
  const messageId = weixinMessageId(message);
@@ -299,6 +379,7 @@ export class WeixinHarnessBridge {
299
379
  askOptions: {
300
380
  timeoutMs: this.#replyTimeoutMs,
301
381
  signal: this.#signal,
382
+ control: { owner: this, key },
302
383
  onInteraction: (interaction) => this.#handleInteraction(interaction, {
303
384
  key,
304
385
  actor: sender,
@@ -320,6 +401,10 @@ export class WeixinHarnessBridge {
320
401
  this.#status.lastReplyAt = new Date().toISOString();
321
402
  this.#status.lastError = null;
322
403
  } catch (error) {
404
+ if (error?.code === 'turn-stopped') {
405
+ await this.#state.markSeen(messageId);
406
+ return;
407
+ }
323
408
  if (this.#signal?.aborted) return;
324
409
  this.#status.lastError = error?.message ?? String(error);
325
410
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
@@ -6,6 +6,10 @@ import {
6
6
  WeixinApiError,
7
7
  } from './weixin-api.mjs';
8
8
  import { deriveWeixinBotIdentity, maskWeixinAccountId } 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([
11
15
  'starting',
@@ -240,6 +244,20 @@ export class WeixinController {
240
244
  return this.status();
241
245
  }
242
246
 
247
+ async sendConnectionTest(botId) {
248
+ const config = this.#configStore.get(botId);
249
+ if (!config) throw new Error('Unknown Weixin account');
250
+ return this.#withBotTransition(botId, async () => {
251
+ const runtime = this.#runtimes.get(botId);
252
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
253
+ throw connectionTestTargetUnavailable('微信机器人');
254
+ }
255
+ return runtime.sendConnectionTest(connectionTestMessage(
256
+ `微信机器人(${maskWeixinAccountId(config.accountId)})`,
257
+ ));
258
+ });
259
+ }
260
+
243
261
  async deleteBot(botId) {
244
262
  const config = this.#configStore.get(botId);
245
263
  if (!config) throw new Error('Unknown Weixin account');
@@ -1,5 +1,9 @@
1
1
  import { WeixinApiError } from './weixin-api.mjs';
2
2
  import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
3
+ import {
4
+ connectionTestTarget,
5
+ connectionTestTargetUnavailable,
6
+ } from '../shared/connection-test.mjs';
3
7
 
4
8
  const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
5
9
 
@@ -250,4 +254,25 @@ export class WeixinRuntime {
250
254
  this.#status.weixinConnectionState = 'idle';
251
255
  return this.status;
252
256
  }
257
+
258
+ async sendConnectionTest(text) {
259
+ const remembered = connectionTestTarget(this.#state);
260
+ const toUserId = typeof remembered?.toUserId === 'string' && remembered.toUserId.trim()
261
+ ? remembered.toUserId.trim()
262
+ : typeof this.#config.ownerUserId === 'string' && this.#config.ownerUserId.trim()
263
+ ? this.#config.ownerUserId.trim()
264
+ : null;
265
+ if (!toUserId) throw connectionTestTargetUnavailable('微信机器人');
266
+ if (!this.#status.ready || !this.#abortController) {
267
+ throw new Error('Weixin runtime is not connected');
268
+ }
269
+ await this.#api.sendText({
270
+ baseUrl: this.#config.baseUrl,
271
+ token: this.#token,
272
+ toUserId,
273
+ text,
274
+ signal: this.#abortController.signal,
275
+ });
276
+ return { sent: true };
277
+ }
253
278
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
3
4
  import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
4
5
 
5
6
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
@@ -198,6 +199,25 @@ export class WhatsappController {
198
199
  return this.status();
199
200
  }
200
201
 
202
+ async sendConnectionTest(botId) {
203
+ const config = this.#configStore.get(botId);
204
+ if (!config) throw new Error('Unknown WhatsApp bot');
205
+ return this.#withBotTransition(botId, async () => {
206
+ const runtime = this.#runtimes.get(botId);
207
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
208
+ const error = new Error('WhatsApp机器人尚未连接');
209
+ error.code = 'test-target-unavailable';
210
+ throw error;
211
+ }
212
+ return runtime.sendConnectionTest(
213
+ connectionTestMessage(
214
+ `${config.name}(${maskWhatsappAccount(config.accountJid)})`,
215
+ 'WhatsApp机器人',
216
+ ),
217
+ );
218
+ });
219
+ }
220
+
201
221
  async deleteBot(botId) {
202
222
  const config = this.#configStore.get(botId);
203
223
  if (!config) throw new Error('Unknown WhatsApp bot');
@@ -421,6 +421,22 @@ export class WhatsappRuntime {
421
421
  return this.stop();
422
422
  }
423
423
 
424
+ async sendConnectionTest(text) {
425
+ if (!this.#status.ready || !this.#client) {
426
+ const error = new Error('WhatsApp机器人尚未连接');
427
+ error.code = 'test-target-unavailable';
428
+ throw error;
429
+ }
430
+ if (typeof text !== 'string' || !text.trim()) {
431
+ throw new TypeError('WhatsApp connection test text is required');
432
+ }
433
+ await this.#client.sendText({
434
+ jid: this.#config.accountJid,
435
+ selfChat: true,
436
+ }, text);
437
+ return { sent: true };
438
+ }
439
+
424
440
  async stop() {
425
441
  const session = this.#session;
426
442
  const client = this.#client;