@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,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);
@@ -78,7 +78,7 @@ export async function createProductionController(ctx, config = {}, internals = {
78
78
  const harness = new Harness({
79
79
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
80
  workspace: defaultWorkspace,
81
- agentPreset: config.agentPreset ?? 'standard',
81
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
82
82
  autostart: false,
83
83
  dshBin: config.dshBin ?? 'dsh',
84
84
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -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(
@@ -78,7 +78,7 @@ export async function createProductionController(ctx, config = {}, internals = {
78
78
  const harness = new Harness({
79
79
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
80
  workspace: defaultWorkspace,
81
- agentPreset: config.agentPreset ?? 'standard',
81
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
82
82
  autostart: false,
83
83
  dshBin: config.dshBin ?? 'dsh',
84
84
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -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(
@@ -81,7 +81,7 @@ export async function createProductionController(ctx, config = {}, internals = {
81
81
  const harness = new Harness({
82
82
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
83
83
  workspace: defaultWorkspace,
84
- agentPreset: config.agentPreset ?? 'standard',
84
+ ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
85
85
  autostart: false,
86
86
  dshBin: config.dshBin ?? 'dsh',
87
87
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -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(
@@ -17,6 +17,7 @@ import {
17
17
  imagePromptUserMessage,
18
18
  promptContentForMessage,
19
19
  } from '../shared/image-prompt.mjs';
20
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
20
21
 
21
22
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
22
23
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -309,6 +310,9 @@ export class DingtalkHarnessBridge {
309
310
  } catch {
310
311
  // An unsafe reply route must never be able to submit an approval.
311
312
  }
313
+ if (sessionWebhook && String(message.conversationType) !== '2') {
314
+ rememberConnectionTestTarget(this.#state, { sessionWebhook });
315
+ }
312
316
  const pending = this.#pendingInteractions.get(key);
313
317
  const approvalReply = this.#approvals.claimReply({
314
318
  key,
@@ -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.11.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,7 @@ 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 { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
21
22
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
22
23
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
23
24
 
@@ -133,6 +134,11 @@ export class FeishuHarnessBridge {
133
134
  return Promise.resolve();
134
135
  }
135
136
 
137
+ if (event.message.chat_type === 'p2p') {
138
+ const chatId = nonEmptyString(event.message.chat_id);
139
+ if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
140
+ }
141
+
136
142
  this.#acceptedMessageIds.add(messageId);
137
143
  const processingReaction = this.#addReaction(messageId, 'OnIt');
138
144
  if (this.#isResolvedQuestionReply(event, key)) {
@@ -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,5 +1,6 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
2
  import { runCompactCommand } from '../shared/compact-command.mjs';
3
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
3
4
  import {
4
5
  harnessAnswerForQuestion,
5
6
  harnessQuestionText,
@@ -176,6 +177,12 @@ export class QqHarnessBridge {
176
177
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
177
178
  const key = conversationKey(message);
178
179
  this.#acceptedMessageIds.add(messageId);
180
+ if (message.kind === 'c2c'
181
+ && (this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid)
182
+ && message.replyTarget?.scope === 'c2c'
183
+ && nonEmptyString(message.replyTarget.targetId) === sender) {
184
+ rememberConnectionTestTarget(this.#state, message.replyTarget);
185
+ }
179
186
  const pending = this.#pendingInteractions.get(key);
180
187
  const approval = this.#approvals.claimReply({
181
188
  key,
@@ -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');