@xmanrui/dsh-im 0.10.0 → 0.11.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 (62) hide show
  1. package/README.en.md +2 -0
  2. package/README.md +2 -0
  3. package/lib/client.js +377 -27
  4. package/lib/index.js +128 -127
  5. package/package.json +1 -1
  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 +1 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +1 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +1 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +1 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -0
  35. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  36. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  37. package/src/channels/discord/discord-api.mjs +1 -1
  38. package/src/channels/discord/discord-runtime.mjs +10 -0
  39. package/src/channels/feishu/bridge.mjs +6 -0
  40. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  41. package/src/channels/feishu/message-utils.mjs +94 -7
  42. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  43. package/src/channels/qq/qq-bridge.mjs +7 -0
  44. package/src/channels/qq/qq-controller.mjs +18 -0
  45. package/src/channels/qq/qq-runtime.mjs +26 -0
  46. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  47. package/src/channels/shared/connection-test.mjs +54 -0
  48. package/src/channels/shared/harness-client.mjs +6 -6
  49. package/src/channels/shared/text-harness-bridge.mjs +19 -0
  50. package/src/channels/shared/token-bot-controller.mjs +21 -0
  51. package/src/channels/slack/slack-controller.mjs +19 -0
  52. package/src/channels/slack/slack-runtime.mjs +10 -0
  53. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  54. package/src/channels/wecom/wecom-bridge.mjs +4 -0
  55. package/src/channels/wecom/wecom-controller.mjs +18 -0
  56. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  57. package/src/channels/weixin/weixin-api.mjs +1 -1
  58. package/src/channels/weixin/weixin-bridge.mjs +4 -0
  59. package/src/channels/weixin/weixin-controller.mjs +18 -0
  60. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  61. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  62. package/src/channels/whatsapp/whatsapp-runtime.mjs +16 -0
@@ -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;
@@ -9,6 +9,7 @@ import {
9
9
  } from 'node:fs/promises';
10
10
  import { dirname, isAbsolute, resolve } from 'node:path';
11
11
 
12
+ import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
12
13
  import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
13
14
 
14
15
  const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
@@ -687,6 +688,7 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
687
688
  });
688
689
  const scopedState = new Proxy(state, {
689
690
  get(target, property) {
691
+ if (property === CONNECTION_TEST_STATE_IDENTITY) return target;
690
692
  if (property === 'sessionFor') {
691
693
  return (key, ...args) => {
692
694
  if (!isCurrentScope()) return null;
@@ -0,0 +1,54 @@
1
+ const targets = new WeakMap();
2
+
3
+ export const CONNECTION_TEST_STATE_IDENTITY = Symbol('dsh-im.connection-test-state-identity');
4
+
5
+ function stateIdentity(state) {
6
+ return state?.[CONNECTION_TEST_STATE_IDENTITY] ?? state;
7
+ }
8
+
9
+ function cleanText(value) {
10
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
11
+ }
12
+
13
+ export function rememberConnectionTestTarget(state, target) {
14
+ if (!state || !target || typeof target !== 'object') return false;
15
+ try {
16
+ targets.set(stateIdentity(state), structuredClone(target));
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export function connectionTestTarget(state) {
24
+ const target = state ? targets.get(stateIdentity(state)) : null;
25
+ return target ? structuredClone(target) : null;
26
+ }
27
+
28
+ export function connectionTestMessage(botName, channelLabel = '机器人') {
29
+ const name = cleanText(botName) ?? channelLabel;
30
+ return `✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“${name}”机器人卡片发出。`;
31
+ }
32
+
33
+ export function connectionTestTargetUnavailable(channelLabel = '机器人') {
34
+ const error = new Error(`${channelLabel}尚未收到可用于测试的私聊消息。`);
35
+ error.code = 'test-target-unavailable';
36
+ return error;
37
+ }
38
+
39
+ export async function sendRememberedConnectionTest({ state, send, text, channelLabel }) {
40
+ const target = connectionTestTarget(state);
41
+ if (!target) throw connectionTestTargetUnavailable(channelLabel);
42
+ await send(target, text);
43
+ return { sent: true };
44
+ }
45
+
46
+ export function publicConnectionTestResult(error) {
47
+ if (!error) return Object.freeze({ sent: true });
48
+ return Object.freeze({
49
+ sent: false,
50
+ code: error?.code === 'test-target-unavailable'
51
+ ? 'test-target-unavailable'
52
+ : 'test-message-failed',
53
+ });
54
+ }
@@ -327,7 +327,7 @@ export class HarnessClient {
327
327
  constructor({
328
328
  baseUrl,
329
329
  workspace,
330
- agentPreset = 'standard',
330
+ agentPreset,
331
331
  autostart = false,
332
332
  dshBin = 'dsh',
333
333
  fetchImpl = fetch,
@@ -354,7 +354,8 @@ export class HarnessClient {
354
354
  }
355
355
  this.#baseUrl = new URL(baseUrl);
356
356
  this.#workspace = workspace;
357
- this.#agentPreset = agentPreset;
357
+ // Keep an omitted preset absent so session.create resolves the Host's current default.
358
+ this.#agentPreset = agentPreset ?? undefined;
358
359
  this.#autostart = autostart;
359
360
  this.#dshBin = dshBin;
360
361
  this.#fetch = fetchImpl;
@@ -458,10 +459,9 @@ export class HarnessClient {
458
459
  async createSession(options = {}) {
459
460
  await this.ensureRunning(options);
460
461
  const workspaceId = await this.workspaceId(options);
461
- const created = await this.rpc('session.create', {
462
- workspaceId,
463
- agentPreset: this.#agentPreset,
464
- }, 30_000, options);
462
+ const payload = { workspaceId };
463
+ if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
464
+ const created = await this.rpc('session.create', payload, 30_000, options);
465
465
  return created.sessionId;
466
466
  }
467
467
 
@@ -1,5 +1,9 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { runCompactCommand } from './compact-command.mjs';
3
+ import {
4
+ rememberConnectionTestTarget,
5
+ sendRememberedConnectionTest,
6
+ } from './connection-test.mjs';
3
7
  import { askInWorkspaceSession } from './workspace-session.mjs';
4
8
  import { HarnessApprovalQueue } from './harness-approval.mjs';
5
9
  import {
@@ -97,6 +101,12 @@ export class TextHarnessBridge {
97
101
  return Promise.resolve();
98
102
  }
99
103
  this.#acceptedMessageIds.add(messageId);
104
+ if (normalized.kind === 'direct') {
105
+ rememberConnectionTestTarget(
106
+ this.#state,
107
+ normalized.connectionTestTarget ?? normalized.replyTarget,
108
+ );
109
+ }
100
110
 
101
111
  const key = `${kind}:${conversationId}`;
102
112
  const pending = this.#pendingInteractions.get(key);
@@ -194,6 +204,15 @@ export class TextHarnessBridge {
194
204
  ]);
195
205
  }
196
206
 
207
+ sendConnectionTest(text) {
208
+ return sendRememberedConnectionTest({
209
+ state: this.#state,
210
+ text,
211
+ channelLabel: `${this.#descriptor.label}机器人`,
212
+ send: (target, message) => this.#bot.sendText(target, message),
213
+ });
214
+ }
215
+
197
216
  async #process(message, messageId, senderId, conversationKey, {
198
217
  alreadyRecorded = false,
199
218
  } = {}) {
@@ -1,3 +1,5 @@
1
+ import { connectionTestMessage } from './connection-test.mjs';
2
+
1
3
  function cleanString(value) {
2
4
  return typeof value === 'string' && value.trim() ? value.trim() : null;
3
5
  }
@@ -161,6 +163,25 @@ export class TokenBotController {
161
163
  return this.status();
162
164
  }
163
165
 
166
+ async sendConnectionTest(botId) {
167
+ const config = this.#configStore.get(botId);
168
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
169
+ return this.#withBotTransition(botId, async () => {
170
+ const runtime = this.#runtimes.get(botId);
171
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
172
+ const error = new Error(`${this.#descriptor.label}机器人尚未连接`);
173
+ error.code = 'test-target-unavailable';
174
+ throw error;
175
+ }
176
+ const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`;
177
+ await runtime.sendConnectionTest(connectionTestMessage(
178
+ cardLabel,
179
+ `${this.#descriptor.label}机器人`,
180
+ ));
181
+ return { sent: true };
182
+ });
183
+ }
184
+
164
185
  async deleteBot(botId) {
165
186
  const config = this.#configStore.get(botId);
166
187
  if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
@@ -1,3 +1,4 @@
1
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
1
2
  import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
2
3
  import { inspectSlackCredentials } from './slack-api.mjs';
3
4
  import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
@@ -168,6 +169,24 @@ export class SlackController {
168
169
  return this.status();
169
170
  }
170
171
 
172
+ async sendConnectionTest(botId) {
173
+ const config = this.#configStore.get(botId);
174
+ if (!config) throw new Error('Unknown Slack bot');
175
+ return this.#withBotTransition(botId, async () => {
176
+ const runtime = this.#runtimes.get(botId);
177
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
178
+ const error = new Error('Slack机器人尚未连接');
179
+ error.code = 'test-target-unavailable';
180
+ throw error;
181
+ }
182
+ await runtime.sendConnectionTest(connectionTestMessage(
183
+ `${config.name}(${maskSlackBotId(config.platformId)})`,
184
+ 'Slack机器人',
185
+ ));
186
+ return { sent: true };
187
+ });
188
+ }
189
+
171
190
  async deleteBot(botId) {
172
191
  const config = this.#configStore.get(botId);
173
192
  if (!config) throw new Error('Unknown Slack bot');
@@ -83,6 +83,7 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
83
83
  recipientUserId: String(event.user),
84
84
  recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
85
85
  },
86
+ connectionTestTarget: { channelId: String(event.channel) },
86
87
  };
87
88
  }
88
89
 
@@ -293,6 +294,15 @@ export class SlackRuntime {
293
294
  return structuredClone(this.#status);
294
295
  }
295
296
 
297
+ async sendConnectionTest(text) {
298
+ if (!this.#status.ready || !this.#bridge) {
299
+ const error = new Error('Slack bot is not connected');
300
+ error.code = 'test-target-unavailable';
301
+ throw error;
302
+ }
303
+ return this.#bridge.sendConnectionTest(text);
304
+ }
305
+
296
306
  async start() {
297
307
  if (this.#status.ready && this.#socket) return this.status;
298
308
  if (this.#starting) return this.#starting;
@@ -111,6 +111,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
111
111
  replyToMessageId: messageId,
112
112
  messageThreadId,
113
113
  },
114
+ connectionTestTarget: { chatId, messageThreadId },
114
115
  };
115
116
  }
116
117
 
@@ -229,6 +230,15 @@ export class TelegramRuntime {
229
230
  return structuredClone(this.#status);
230
231
  }
231
232
 
233
+ async sendConnectionTest(text) {
234
+ if (!this.#status.ready || !this.#bridge) {
235
+ const error = new Error('Telegram bot is not connected');
236
+ error.code = 'test-target-unavailable';
237
+ throw error;
238
+ }
239
+ return this.#bridge.sendConnectionTest(text);
240
+ }
241
+
232
242
  async start() {
233
243
  if (this.#status.ready && this.#pollTask) return this.status;
234
244
  if (this.#starting) return this.#starting;
@@ -14,6 +14,7 @@ import {
14
14
  imagePromptUserMessage,
15
15
  promptContentForMessage,
16
16
  } from '../shared/image-prompt.mjs';
17
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
17
18
 
18
19
  const HELP_TEXT = [
19
20
  '企业微信机器人已连接 DeepSeek Harness。',
@@ -269,6 +270,9 @@ export class WecomHarnessBridge {
269
270
 
270
271
  const key = conversationKey(frame);
271
272
  this.#acceptedMessageIds.add(messageId);
273
+ if (body.chattype === 'single') {
274
+ rememberConnectionTestTarget(this.#state, { chatId });
275
+ }
272
276
  const pending = this.#pendingInteractions.get(key);
273
277
  const approval = this.#approvals.claimReply({
274
278
  key,
@@ -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.11.0',
188
188
  };
189
189
  }
190
190
 
@@ -18,6 +18,7 @@ import {
18
18
  imagePromptUserMessage,
19
19
  promptContentForMessage,
20
20
  } from '../shared/image-prompt.mjs';
21
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
21
22
 
22
23
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
23
24
 
@@ -128,6 +129,9 @@ export class WeixinHarnessBridge {
128
129
  if (!messageId || !sender || this.#state.hasSeen(messageId)
129
130
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
130
131
  this.#acceptedMessageIds.add(messageId);
132
+ if (sender === this.#ownerUserId) {
133
+ rememberConnectionTestTarget(this.#state, { toUserId: sender });
134
+ }
131
135
  const key = conversationKey(sender);
132
136
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
133
137
  const runId = nonEmptyString(message?.run_id) ?? undefined;
@@ -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;