@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
@@ -1,4 +1,5 @@
1
1
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
2
+ import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
2
3
  import {
3
4
  publicWorkspaceError,
4
5
  SET_WORKSPACE_ENDPOINT,
@@ -44,7 +45,8 @@ function payloadFailure(endpoint, payload) {
44
45
  ? null : 'bot.bind-credentials requires a Bot Token.';
45
46
  }
46
47
  if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
47
- return exactKeys(payload, ['botId']) && validId(payload.botId)
48
+ return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
49
+ && (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
48
50
  ? null : 'bot.reconnect requires a botId.';
49
51
  }
50
52
  if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) {
@@ -105,6 +107,28 @@ export function createTokenBotRpcHandler(controller, { channel }) {
105
107
  value = await controller.bindCredentials(payload);
106
108
  } else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
107
109
  value = await controller.reconnectBot(payload.botId);
110
+ if (signal?.aborted) {
111
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
112
+ }
113
+ if (payload.sendTest === true) {
114
+ let testError = null;
115
+ try {
116
+ if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
117
+ const unavailable = new Error('Bot is not connected');
118
+ unavailable.code = 'test-target-unavailable';
119
+ throw unavailable;
120
+ }
121
+ if (typeof controller.sendConnectionTest !== 'function') {
122
+ const unavailable = new Error('Connection test is unavailable');
123
+ unavailable.code = 'test-target-unavailable';
124
+ throw unavailable;
125
+ }
126
+ await controller.sendConnectionTest(payload.botId);
127
+ } catch (error) {
128
+ testError = error;
129
+ }
130
+ value = { ...value, testMessage: publicConnectionTestResult(testError) };
131
+ }
108
132
  } else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
109
133
  if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
110
134
  value = await controller.updateWorkspace(payload.botId, payload.workspace);
@@ -15,6 +15,7 @@ import {
15
15
  import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
16
16
  import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
17
17
  import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
18
+ import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
18
19
 
19
20
  export async function createProductionController(ctx, config = {}, internals = {}) {
20
21
  if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
@@ -51,13 +52,19 @@ export async function createProductionController(ctx, config = {}, internals = {
51
52
  return state;
52
53
  };
53
54
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
55
+ const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
56
+ controlExecutor: internals.controlExecutor,
57
+ sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
58
+ });
54
59
  const harness = new ResolvedHarness({
55
60
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
56
61
  workspace: defaultWorkspace,
57
- agentPreset: config.agentPreset ?? 'standard',
62
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
58
63
  autostart: false,
59
64
  dshBin: config.dshBin ?? 'dsh',
60
65
  ...(commandExecutor ? { commandExecutor } : {}),
66
+ ...(controlExecutor ? { controlExecutor } : {}),
67
+ ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
61
68
  });
62
69
  const coreController = new ResolvedController({
63
70
  credentials: ctx.credentials,
@@ -1,4 +1,5 @@
1
1
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
2
+ import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
2
3
  import {
3
4
  publicWorkspaceError,
4
5
  SET_WORKSPACE_ENDPOINT,
@@ -53,7 +54,8 @@ function payloadFailure(endpoint, payload) {
53
54
  ? null : 'bot.bind-credentials requires xoxb Bot Token and xapp App Token.';
54
55
  }
55
56
  if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
56
- return exactKeys(payload, ['botId']) && validId(payload.botId)
57
+ return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
58
+ && (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
57
59
  ? null : 'bot.reconnect requires a botId.';
58
60
  }
59
61
  if (endpoint === SLACK_ENDPOINTS.deleteBot) {
@@ -114,7 +116,31 @@ export function createSlackRpcHandler(controller) {
114
116
  let value;
115
117
  if (endpoint === SLACK_ENDPOINTS.status) value = await controller.status();
116
118
  else if (endpoint === SLACK_ENDPOINTS.bindCredentials) value = await controller.bindCredentials(payload);
117
- else if (endpoint === SLACK_ENDPOINTS.reconnectBot) value = await controller.reconnectBot(payload.botId);
119
+ else if (endpoint === SLACK_ENDPOINTS.reconnectBot) {
120
+ value = await controller.reconnectBot(payload.botId);
121
+ if (signal?.aborted) {
122
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
123
+ }
124
+ if (payload.sendTest === true) {
125
+ let testError = null;
126
+ try {
127
+ if (value?.bots?.find((bot) => bot?.botId === payload.botId)?.connected !== true) {
128
+ const unavailable = new Error('Bot is not connected');
129
+ unavailable.code = 'test-target-unavailable';
130
+ throw unavailable;
131
+ }
132
+ if (typeof controller.sendConnectionTest !== 'function') {
133
+ const unavailable = new Error('Connection test is unavailable');
134
+ unavailable.code = 'test-target-unavailable';
135
+ throw unavailable;
136
+ }
137
+ await controller.sendConnectionTest(payload.botId);
138
+ } catch (error) {
139
+ testError = error;
140
+ }
141
+ value = { ...value, testMessage: publicConnectionTestResult(testError) };
142
+ }
143
+ }
118
144
  else if (endpoint === SLACK_ENDPOINTS.setWorkspace) {
119
145
  if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
120
146
  value = await controller.updateWorkspace(payload.botId, payload.workspace);
@@ -16,6 +16,7 @@ import {
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
17
  import { createConnectionSupervisor } from './connection-supervisor.mjs';
18
18
  import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
19
+ import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
19
20
 
20
21
  function harnessOrigin(webServer, configured) {
21
22
  if (configured !== undefined) return new URL(configured);
@@ -75,13 +76,19 @@ export async function createProductionController(ctx, config = {}, internals = {
75
76
  return state;
76
77
  };
77
78
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
79
+ const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
80
+ controlExecutor: internals.controlExecutor,
81
+ sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
82
+ });
78
83
  const harness = new Harness({
79
84
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
85
  workspace: defaultWorkspace,
81
- agentPreset: config.agentPreset ?? 'standard',
86
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
82
87
  autostart: false,
83
88
  dshBin: config.dshBin ?? 'dsh',
84
89
  ...(commandExecutor ? { commandExecutor } : {}),
90
+ ...(controlExecutor ? { controlExecutor } : {}),
91
+ ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
85
92
  });
86
93
  const coreController = new Controller({
87
94
  qrAuth,
@@ -1,6 +1,10 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
3
  import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
4
+ import {
5
+ connectionTestTargetUnavailable,
6
+ publicConnectionTestResult,
7
+ } from '../../../../src/channels/shared/connection-test.mjs';
4
8
 
5
9
  export const WECOM_RPC_CHANNEL = '/wecom';
6
10
  export const WECOM_ENDPOINTS = Object.freeze({
@@ -53,7 +57,10 @@ function payloadFailure(endpoint, payload) {
53
57
  ? null : 'bot.bind-credentials requires Bot ID and Secret.';
54
58
  }
55
59
  if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
56
- return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
60
+ return exactKeys(payload, ['botId', 'sendTest'])
61
+ && validId(payload.botId)
62
+ && (payload.sendTest === undefined || payload.sendTest === true)
63
+ ? null : 'bot.reconnect requires a botId and optional sendTest=true.';
57
64
  }
58
65
  if (endpoint === WECOM_ENDPOINTS.deleteBot) {
59
66
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@@ -130,7 +137,29 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
130
137
  } else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
131
138
  value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
132
139
  } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
133
- value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
140
+ const snapshot = await controller.reconnectBot(payload.botId);
141
+ if (signal?.aborted) {
142
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
143
+ }
144
+ let testMessage;
145
+ if (payload.sendTest === true) {
146
+ const connected = snapshot?.bots?.some(
147
+ (bot) => bot?.botId === payload.botId && bot?.connected === true,
148
+ );
149
+ if (!connected || typeof controller.sendConnectionTest !== 'function') {
150
+ testMessage = publicConnectionTestResult(
151
+ connectionTestTargetUnavailable('企业微信机器人'),
152
+ );
153
+ } else {
154
+ try {
155
+ await controller.sendConnectionTest(payload.botId);
156
+ testMessage = publicConnectionTestResult();
157
+ } catch (error) {
158
+ testMessage = publicConnectionTestResult(error);
159
+ }
160
+ }
161
+ }
162
+ value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
134
163
  } else if (endpoint === WECOM_ENDPOINTS.setWorkspace) {
135
164
  if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
136
165
  value = await publicStatus(
@@ -16,6 +16,7 @@ import {
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
17
  import { createConnectionSupervisor } from './connection-supervisor.mjs';
18
18
  import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
19
+ import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
19
20
 
20
21
  function harnessOrigin(webServer, configured) {
21
22
  if (configured !== undefined) return new URL(configured);
@@ -75,13 +76,19 @@ export async function createProductionController(ctx, config = {}, internals = {
75
76
  return state;
76
77
  };
77
78
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
79
+ const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
80
+ controlExecutor: internals.controlExecutor,
81
+ sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
82
+ });
78
83
  const harness = new Harness({
79
84
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
85
  workspace: defaultWorkspace,
81
- agentPreset: config.agentPreset ?? 'standard',
86
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
82
87
  autostart: false,
83
88
  dshBin: config.dshBin ?? 'dsh',
84
89
  ...(commandExecutor ? { commandExecutor } : {}),
90
+ ...(controlExecutor ? { controlExecutor } : {}),
91
+ ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
85
92
  });
86
93
  const coreController = new Controller({
87
94
  api,
@@ -5,6 +5,10 @@ import {
5
5
  SET_WORKSPACE_ENDPOINT,
6
6
  validWorkspacePayload,
7
7
  } from '../shared/workspace-rpc.mjs';
8
+ import {
9
+ connectionTestTargetUnavailable,
10
+ publicConnectionTestResult,
11
+ } from '../../../../src/channels/shared/connection-test.mjs';
8
12
 
9
13
  export const WEIXIN_RPC_CHANNEL = '/weixin';
10
14
  export const WEIXIN_ENDPOINTS = Object.freeze({
@@ -55,9 +59,11 @@ function payloadFailure(endpoint, payload) {
55
59
  : 'provision.verify requires an attemptId and a 4-to-8-digit code.';
56
60
  }
57
61
  if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
58
- return exactKeys(payload, ['botId']) && validId(payload.botId)
62
+ return exactKeys(payload, ['botId', 'sendTest'])
63
+ && validId(payload.botId)
64
+ && (payload.sendTest === undefined || payload.sendTest === true)
59
65
  ? null
60
- : 'bot.reconnect requires a botId.';
66
+ : 'bot.reconnect requires a botId and optional sendTest=true.';
61
67
  }
62
68
  if (endpoint === WEIXIN_ENDPOINTS.deleteBot) {
63
69
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
@@ -165,7 +171,27 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
165
171
  value = await controller.cancelProvisioning(payload.attemptId);
166
172
  if (!value) return badRequest('The provisioning attempt no longer exists.');
167
173
  } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
168
- value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
174
+ const snapshot = await controller.reconnectBot(payload.botId);
175
+ if (signal?.aborted) return cancelled();
176
+ let testMessage;
177
+ if (payload.sendTest === true) {
178
+ const connected = snapshot?.bots?.some(
179
+ (bot) => bot?.botId === payload.botId && bot?.connected === true,
180
+ );
181
+ if (!connected || typeof controller.sendConnectionTest !== 'function') {
182
+ testMessage = publicConnectionTestResult(
183
+ connectionTestTargetUnavailable('微信机器人'),
184
+ );
185
+ } else {
186
+ try {
187
+ await controller.sendConnectionTest(payload.botId);
188
+ testMessage = publicConnectionTestResult();
189
+ } catch (error) {
190
+ testMessage = publicConnectionTestResult(error);
191
+ }
192
+ }
193
+ }
194
+ value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) }, cachedEncode);
169
195
  } else if (endpoint === WEIXIN_ENDPOINTS.setWorkspace) {
170
196
  if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
171
197
  value = await publicStatus(
@@ -16,6 +16,7 @@ import {
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
17
  import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
18
18
  import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
19
+ import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
19
20
 
20
21
  const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
21
22
 
@@ -78,13 +79,19 @@ export async function createProductionController(ctx, config = {}, internals = {
78
79
  return state;
79
80
  };
80
81
  const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
82
+ const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
83
+ controlExecutor: internals.controlExecutor,
84
+ sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
85
+ });
81
86
  const harness = new Harness({
82
87
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
83
88
  workspace: defaultWorkspace,
84
- agentPreset: config.agentPreset ?? 'standard',
89
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
85
90
  autostart: false,
86
91
  dshBin: config.dshBin ?? 'dsh',
87
92
  ...(commandExecutor ? { commandExecutor } : {}),
93
+ ...(controlExecutor ? { controlExecutor } : {}),
94
+ ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
88
95
  });
89
96
  const coreController = new Controller({
90
97
  configStore: observedConfigStore,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
 
3
+ import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
3
4
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
4
5
  import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
5
6
 
@@ -39,8 +40,9 @@ function payloadFailure(endpoint, payload) {
39
40
  ? null : `${endpoint} requires an attemptId.`;
40
41
  }
41
42
  if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
42
- return exactKeys(payload, ['botId']) && validId(payload.botId)
43
- ? null : 'bot.reconnect requires a botId.';
43
+ return exactKeys(payload, ['botId', 'sendTest']) && validId(payload.botId)
44
+ && (payload.sendTest === undefined || typeof payload.sendTest === 'boolean')
45
+ ? null : 'bot.reconnect requires a botId and optional sendTest flag.';
44
46
  }
45
47
  if (endpoint === WHATSAPP_ENDPOINTS.deleteBot) {
46
48
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
@@ -119,7 +121,33 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
119
121
  } else if (endpoint === WHATSAPP_ENDPOINTS.cancelProvisioning) {
120
122
  value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
121
123
  } else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
122
- value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
124
+ const checked = await controller.reconnectBot(payload.botId);
125
+ if (signal?.aborted) {
126
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
127
+ }
128
+ value = await publicStatus(checked, cachedEncode);
129
+ if (payload.sendTest === true) {
130
+ let testError = null;
131
+ const connected = checked?.bots?.some(
132
+ (bot) => bot?.botId === payload.botId && bot.connected === true,
133
+ ) === true;
134
+ if (!connected) {
135
+ testError = new Error('WhatsApp bot is not connected');
136
+ testError.code = 'test-target-unavailable';
137
+ } else {
138
+ try {
139
+ if (typeof controller.sendConnectionTest !== 'function') {
140
+ const unavailable = new Error('Connection test is unavailable');
141
+ unavailable.code = 'test-target-unavailable';
142
+ throw unavailable;
143
+ }
144
+ await controller.sendConnectionTest(payload.botId);
145
+ } catch (error) {
146
+ testError = error;
147
+ }
148
+ }
149
+ value = { ...value, testMessage: publicConnectionTestResult(testError) };
150
+ }
123
151
  } else if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
124
152
  if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
125
153
  value = await publicStatus(
@@ -0,0 +1,134 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ function agentsFromContext(ctx) {
4
+ if (typeof ctx?.get !== 'function') return undefined;
5
+ let agents;
6
+ try {
7
+ agents = ctx.get('agents');
8
+ } catch {
9
+ return undefined;
10
+ }
11
+ if (agents === undefined || agents === null) return undefined;
12
+ if (typeof agents.get !== 'function') {
13
+ throw new TypeError('dsh-im requires a callable AgentRegistry when ctx.get("agents") is present');
14
+ }
15
+ return agents;
16
+ }
17
+
18
+ function currentOwnedTurn(agent, expectedTurn, promptRpcId) {
19
+ if (agent?.status !== 'running') return false;
20
+ const events = agent?.session?.events;
21
+ if (!Array.isArray(events)) return false;
22
+
23
+ let openTurn = null;
24
+ let owned = false;
25
+ for (const event of events) {
26
+ if (event?.type === 'turn/start') {
27
+ openTurn = event.data?.turn ?? null;
28
+ owned = false;
29
+ continue;
30
+ }
31
+ if (event?.type === 'turn/end' && event.data?.turn === openTurn) {
32
+ openTurn = null;
33
+ owned = false;
34
+ continue;
35
+ }
36
+ if (openTurn === expectedTurn
37
+ && event?.type === 'user/message'
38
+ && event.data?.source?.rpcId === promptRpcId) {
39
+ owned = true;
40
+ }
41
+ }
42
+ return openTurn === expectedTurn && owned;
43
+ }
44
+
45
+ function deepFreeze(value) {
46
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
47
+ for (const child of Object.values(value)) deepFreeze(child);
48
+ return Object.freeze(value);
49
+ }
50
+
51
+ function steeringMessage(text) {
52
+ return deepFreeze({
53
+ id: randomUUID(),
54
+ role: 'user',
55
+ content: [{ type: 'text', text }],
56
+ source: { kind: 'user' },
57
+ });
58
+ }
59
+
60
+ function agentBusyError(cause) {
61
+ const error = new Error('Session is busy with an active turn or maintenance task', { cause });
62
+ error.code = 'agent-busy';
63
+ return error;
64
+ }
65
+
66
+ function createControlExecutor(agents) {
67
+ return ({ sessionId, expectedTurn, promptRpcId, action, text }) => {
68
+ const agent = agents.get(sessionId);
69
+ // An unattached Session cannot be coordinated in-process. Let the client
70
+ // retain its legacy HTTP path for deployments where attachment is lazy.
71
+ if (!agent) return undefined;
72
+ if (!currentOwnedTurn(agent, expectedTurn, promptRpcId)) return false;
73
+
74
+ if (action === 'stop') {
75
+ agent.cancel({ kind: 'user' }, { keepInbox: true });
76
+ return true;
77
+ }
78
+ if (action === 'steer') {
79
+ if (typeof text !== 'string' || !text.trim()) return false;
80
+ // inject() never wakes an idle driver. Because validation and injection
81
+ // share one JS tick, this context can only target this live turn's next step.
82
+ agent.inject(steeringMessage(text));
83
+ return true;
84
+ }
85
+ throw new TypeError(`Unsupported Harness control action: ${String(action)}`);
86
+ };
87
+ }
88
+
89
+ function createSessionMaintenanceExecutor(agents) {
90
+ return ({ sessionId, operation }) => {
91
+ if (typeof operation !== 'function') throw new TypeError('maintenance operation is required');
92
+ const agent = agents.get(sessionId);
93
+ if (!agent) return operation();
94
+ try {
95
+ // runMaintenance claims the true-idle phase synchronously. A prompt or a
96
+ // second maintenance operation therefore cannot interleave with the RPC.
97
+ return agent.runMaintenance((signal) => {
98
+ if (agents.get(sessionId) !== agent) {
99
+ const error = new Error('Session agent changed before maintenance started');
100
+ error.code = 'agent-unavailable';
101
+ throw error;
102
+ }
103
+ return operation(signal);
104
+ });
105
+ } catch (error) {
106
+ throw agentBusyError(error);
107
+ }
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Build optional same-process Session executors without adding a hard Cordis
113
+ * service injection. Fixtures and deployments without AgentRegistry preserve
114
+ * the existing HTTP behavior.
115
+ */
116
+ export function createHarnessSessionExecutors(ctx, provided = {}) {
117
+ const { controlExecutor, sessionMaintenanceExecutor } = provided;
118
+ if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
119
+ throw new TypeError('controlExecutor must be a function');
120
+ }
121
+ if (sessionMaintenanceExecutor !== undefined
122
+ && typeof sessionMaintenanceExecutor !== 'function') {
123
+ throw new TypeError('sessionMaintenanceExecutor must be a function');
124
+ }
125
+
126
+ const agents = controlExecutor && sessionMaintenanceExecutor
127
+ ? undefined
128
+ : agentsFromContext(ctx);
129
+ return {
130
+ controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
131
+ sessionMaintenanceExecutor: sessionMaintenanceExecutor
132
+ ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
133
+ };
134
+ }
@@ -10,6 +10,14 @@ import {
10
10
  } from '../shared/harness-question.mjs';
11
11
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
12
12
  import { runCompactCommand } from '../shared/compact-command.mjs';
13
+ import {
14
+ isControlCommand,
15
+ runControlCommand,
16
+ } from '../shared/control-command.mjs';
17
+ import {
18
+ isModelCommand,
19
+ runModelCommand,
20
+ } from '../shared/model-command.mjs';
13
21
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
14
22
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
15
23
  import {
@@ -17,6 +25,7 @@ import {
17
25
  imagePromptUserMessage,
18
26
  promptContentForMessage,
19
27
  } from '../shared/image-prompt.mjs';
28
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
20
29
 
21
30
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
22
31
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -32,6 +41,10 @@ const HELP_TEXT = [
32
41
  '/workspacelist 列出工作区绝对路径',
33
42
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
34
43
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
44
+ '/models 列出所有可用模型',
45
+ '/model [模型ID] 查看或切换当前会话模型',
46
+ '/stop 停止当前任务',
47
+ '/steer 补充指令 纠偏当前任务',
35
48
  '/status 检查连接状态',
36
49
  '/help 显示本帮助',
37
50
  ].join('\n');
@@ -245,6 +258,7 @@ export class DingtalkHarnessBridge {
245
258
  #pendingInteractions = new Map();
246
259
  #interactionKeys = new Map();
247
260
  #interactionTasks = new Set();
261
+ #commandTasks = new Set();
248
262
  #acceptedMessageIds = new Set();
249
263
  #approvals;
250
264
 
@@ -309,7 +323,41 @@ export class DingtalkHarnessBridge {
309
323
  } catch {
310
324
  // An unsafe reply route must never be able to submit an approval.
311
325
  }
326
+ if (sessionWebhook && String(message.conversationType) !== '2') {
327
+ rememberConnectionTestTarget(this.#state, { sessionWebhook });
328
+ }
312
329
  const pending = this.#pendingInteractions.get(key);
330
+ const promptMessage = dingtalkInboundMessage(message, {
331
+ api: this.#api,
332
+ clientId: this.#clientId,
333
+ clientSecret: this.#clientSecret,
334
+ });
335
+ const commandText = nonEmptyString(promptMessage.content) ?? '';
336
+ const commandRunner = isControlCommand(commandText)
337
+ ? runControlCommand
338
+ : (isModelCommand(commandText) ? runModelCommand : null);
339
+ const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
340
+ if (commandRunner && sessionWebhook && addressed) {
341
+ let task;
342
+ task = this.#processFastCommand(
343
+ message,
344
+ messageId,
345
+ key,
346
+ sessionWebhook,
347
+ promptMessage,
348
+ commandRunner,
349
+ ).catch((error) => {
350
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
351
+ this.#status.lastError = '钉钉命令处理失败。';
352
+ this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
353
+ return this.#send(sessionWebhook, CARD_ERROR_TEXT).catch(() => undefined);
354
+ }).finally(() => {
355
+ this.#acceptedMessageIds.delete(messageId);
356
+ this.#commandTasks.delete(task);
357
+ });
358
+ this.#commandTasks.add(task);
359
+ return task;
360
+ }
313
361
  const approvalReply = this.#approvals.claimReply({
314
362
  key,
315
363
  actor: sender,
@@ -408,9 +456,41 @@ export class DingtalkHarnessBridge {
408
456
  pending.queue ? [pending.queue] : []
409
457
  )),
410
458
  ...this.#interactionTasks,
459
+ ...this.#commandTasks,
411
460
  ]);
412
461
  }
413
462
 
463
+ async #processFastCommand(message, messageId, key, sessionWebhook, prompt, runner) {
464
+ this.#signal?.throwIfAborted();
465
+ if (this.#state.hasSeen(messageId)) return;
466
+ await this.#state.markSeen(messageId);
467
+ increment(this.#status, 'messagesReceived');
468
+ this.#status.lastMessageAt = new Date().toISOString();
469
+ const result = await runner(
470
+ nonEmptyString(prompt.content) ?? '',
471
+ this.#harness,
472
+ this.#state,
473
+ key,
474
+ {
475
+ signal: this.#signal,
476
+ hasImages: hasInboundImages(prompt),
477
+ pendingInteraction: this.#pendingInteractions.has(key)
478
+ || this.#approvals.hasPending(key),
479
+ control: { owner: this, key },
480
+ },
481
+ );
482
+ if (result?.stopped) {
483
+ await Promise.allSettled([
484
+ this.#cancelPendingInteraction(key),
485
+ this.#approvals.closeRoute(key),
486
+ ]);
487
+ }
488
+ for (const reply of result?.messages ?? [result?.message]) {
489
+ if (reply) await this.#send(sessionWebhook, reply);
490
+ }
491
+ this.#status.lastError = null;
492
+ }
493
+
414
494
  async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
415
495
  this.#signal?.throwIfAborted();
416
496
  if (!alreadyRecorded) {
@@ -515,6 +595,7 @@ export class DingtalkHarnessBridge {
515
595
  askOptions: {
516
596
  timeoutMs: this.#replyTimeoutMs,
517
597
  signal: this.#signal,
598
+ control: { owner: this, key },
518
599
  onUpdate: cardStarted
519
600
  ? (update) => cardStream.push(progressText(update))
520
601
  : undefined,
@@ -533,6 +614,10 @@ export class DingtalkHarnessBridge {
533
614
  this.#status.lastReplyAt = new Date().toISOString();
534
615
  this.#status.lastError = null;
535
616
  } catch (error) {
617
+ if (error?.code === 'turn-stopped') {
618
+ if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
619
+ return;
620
+ }
536
621
  if (this.#signal?.aborted) return;
537
622
  this.#status.lastError = '钉钉消息处理失败。';
538
623
  this.#logger.error?.(