@xmanrui/dsh-im 0.9.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 (69) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/lib/client.js +378 -27
  4. package/lib/index.js +134 -131
  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-api.mjs +82 -1
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
  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 +54 -1
  40. package/src/channels/feishu/bridge.mjs +45 -18
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +227 -6
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/feishu/plugin-controller.mjs +1 -0
  45. package/src/channels/qq/qq-bridge.mjs +107 -20
  46. package/src/channels/qq/qq-controller.mjs +18 -0
  47. package/src/channels/qq/qq-runtime.mjs +26 -0
  48. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  49. package/src/channels/shared/connection-test.mjs +54 -0
  50. package/src/channels/shared/harness-client.mjs +14 -8
  51. package/src/channels/shared/image-prompt.mjs +268 -0
  52. package/src/channels/shared/text-harness-bridge.mjs +62 -16
  53. package/src/channels/shared/token-bot-controller.mjs +21 -0
  54. package/src/channels/shared/workspace-session.mjs +2 -1
  55. package/src/channels/slack/manifest.mjs +1 -0
  56. package/src/channels/slack/slack-api.mjs +95 -0
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +34 -3
  59. package/src/channels/telegram/telegram-api.mjs +37 -0
  60. package/src/channels/telegram/telegram-runtime.mjs +81 -9
  61. package/src/channels/wecom/wecom-bridge.mjs +160 -23
  62. package/src/channels/wecom/wecom-controller.mjs +18 -0
  63. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  64. package/src/channels/weixin/weixin-api.mjs +98 -2
  65. package/src/channels/weixin/weixin-bridge.mjs +52 -19
  66. package/src/channels/weixin/weixin-controller.mjs +18 -0
  67. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  69. package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -1
@@ -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(
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
4
+
3
5
  export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
4
6
  export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
5
7
  export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
@@ -14,6 +16,7 @@ export class DingtalkApiError extends Error {
14
16
  this.name = 'DingtalkApiError';
15
17
  this.code = code;
16
18
  this.status = options.status;
19
+ this.providerCode = options.providerCode;
17
20
  }
18
21
  }
19
22
 
@@ -21,6 +24,25 @@ function nonEmptyString(value) {
21
24
  return typeof value === 'string' && value.trim() ? value.trim() : null;
22
25
  }
23
26
 
27
+ function safeProviderCode(value) {
28
+ const code = nonEmptyString(value);
29
+ return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
30
+ }
31
+
32
+ function secureDingtalkDownloadUrl(value) {
33
+ let url;
34
+ try {
35
+ url = new URL(value);
36
+ } catch (error) {
37
+ throw new DingtalkApiError('invalid-image-download', '钉钉服务返回了无效的图片下载地址。', { cause: error });
38
+ }
39
+ if (url.protocol === 'http:' && (!url.port || url.port === '80')) {
40
+ url.protocol = 'https:';
41
+ url.port = '';
42
+ }
43
+ return url;
44
+ }
45
+
24
46
  function isDingtalkHost(hostname) {
25
47
  const normalized = hostname.toLowerCase().replace(/\.$/, '');
26
48
  return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com');
@@ -123,10 +145,17 @@ async function requestJson(fetchImpl, url, {
123
145
  signal: controller.signal,
124
146
  });
125
147
  if (!response.ok) {
148
+ let providerCode;
149
+ try {
150
+ const errorBody = await response.json();
151
+ providerCode = safeProviderCode(errorBody?.code ?? errorBody?.errcode);
152
+ } catch {
153
+ // DingTalk occasionally returns an empty or non-JSON error body.
154
+ }
126
155
  throw new DingtalkApiError(
127
156
  'http-error',
128
157
  `钉钉服务请求失败(HTTP ${response.status})。`,
129
- { status: response.status },
158
+ { status: response.status, providerCode },
130
159
  );
131
160
  }
132
161
  try {
@@ -403,6 +432,58 @@ export function createDingtalkApi({
403
432
 
404
433
  accessToken,
405
434
 
435
+ async downloadImage({
436
+ clientId,
437
+ clientSecret,
438
+ robotCode,
439
+ downloadCode,
440
+ signal,
441
+ maxBytes,
442
+ }) {
443
+ const botCode = nonEmptyString(robotCode);
444
+ const fileCode = nonEmptyString(downloadCode);
445
+ if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
446
+ const token = await accessToken({ clientId, clientSecret, signal });
447
+ let response;
448
+ try {
449
+ response = await requestJson(
450
+ fetchImpl,
451
+ endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
452
+ {
453
+ body: { downloadCode: fileCode, robotCode: botCode },
454
+ headers: { 'x-acs-dingtalk-access-token': token },
455
+ signal,
456
+ action: '图片下载地址',
457
+ },
458
+ );
459
+ } catch (error) {
460
+ if (signal?.aborted) throw error;
461
+ throw new DingtalkApiError(
462
+ 'image-download-address-failed',
463
+ '钉钉图片下载地址获取失败。',
464
+ { cause: error, status: error?.status, providerCode: error?.providerCode },
465
+ );
466
+ }
467
+ const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
468
+ if (!downloadUrl) {
469
+ throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
470
+ }
471
+ try {
472
+ return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
473
+ fetchImpl,
474
+ signal,
475
+ maxBytes,
476
+ });
477
+ } catch (error) {
478
+ if (signal?.aborted || error instanceof ImagePromptError) throw error;
479
+ throw new DingtalkApiError(
480
+ 'image-content-download-failed',
481
+ '钉钉图片内容下载失败。',
482
+ { cause: error },
483
+ );
484
+ }
485
+ },
486
+
406
487
  async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
407
488
  const appKey = nonEmptyString(clientId);
408
489
  const appSecret = nonEmptyString(clientSecret);
@@ -12,6 +12,12 @@ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
12
12
  import { runCompactCommand } from '../shared/compact-command.mjs';
13
13
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
14
14
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
15
+ import {
16
+ hasInboundImages,
17
+ imagePromptUserMessage,
18
+ promptContentForMessage,
19
+ } from '../shared/image-prompt.mjs';
20
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
15
21
 
16
22
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
17
23
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -20,7 +26,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
20
26
  const HELP_TEXT = [
21
27
  '钉钉机器人已连接 DeepSeek Harness。',
22
28
  '',
23
- '直接发送文字即可继续当前会话。',
29
+ '直接发送文字或图片即可继续当前会话。',
24
30
  '/new 开启一个全新会话',
25
31
  '/compact 压缩当前会话的较早上下文',
26
32
  '/workspace 工作区绝对路径 切换工作区',
@@ -35,10 +41,123 @@ function nonEmptyString(value) {
35
41
  return typeof value === 'string' && value.trim() ? value.trim() : null;
36
42
  }
37
43
 
44
+ function safeErrorDiagnostic(error) {
45
+ const chain = [];
46
+ const seen = new Set();
47
+ let current = error;
48
+ while (current && typeof current === 'object' && chain.length < 3 && !seen.has(current)) {
49
+ seen.add(current);
50
+ const name = nonEmptyString(current.name)?.slice(0, 80);
51
+ const code = nonEmptyString(current.code)?.slice(0, 80);
52
+ const providerCode = nonEmptyString(current.providerCode)?.slice(0, 160);
53
+ const status = Number.isInteger(current.status) ? current.status : undefined;
54
+ chain.push({
55
+ ...(name ? { name } : {}),
56
+ ...(code ? { code } : {}),
57
+ ...(providerCode ? { providerCode } : {}),
58
+ ...(status ? { status } : {}),
59
+ });
60
+ current = current.cause;
61
+ }
62
+ return chain;
63
+ }
64
+
65
+ function dingtalkImageErrorUserMessage(error) {
66
+ let current = error;
67
+ const seen = new Set();
68
+ while (current && typeof current === 'object' && !seen.has(current)) {
69
+ seen.add(current);
70
+ if (current.code === 'image-download-address-failed') {
71
+ return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
72
+ }
73
+ if (current.code === 'invalid-image-download') {
74
+ return '钉钉没有返回图片下载地址,请重新发送。';
75
+ }
76
+ if (current.code === 'image-content-download-failed') {
77
+ return '钉钉返回的图片临时地址无法读取,请重新发送。';
78
+ }
79
+ current = current.cause;
80
+ }
81
+ return imagePromptUserMessage(error);
82
+ }
83
+
38
84
  function senderStaffId(message) {
39
85
  return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId);
40
86
  }
41
87
 
88
+ function parsedMessageContent(message) {
89
+ if (message?.content && typeof message.content === 'object') return message.content;
90
+ if (typeof message?.content !== 'string') return null;
91
+ try {
92
+ const parsed = JSON.parse(message.content);
93
+ return parsed && typeof parsed === 'object' ? parsed : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function richTextEntries(content) {
100
+ const entries = content?.richText ?? content?.rich_text;
101
+ return Array.isArray(entries) ? entries : [];
102
+ }
103
+
104
+ function richTextEntryText(entry) {
105
+ if (typeof entry?.text === 'string') return nonEmptyString(entry.text);
106
+ if (typeof entry?.text?.content === 'string') return nonEmptyString(entry.text.content);
107
+ if (String(entry?.type).toLowerCase() === 'text' && typeof entry?.content === 'string') {
108
+ return nonEmptyString(entry.content);
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function downloadCodeFor(value) {
114
+ return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
115
+ }
116
+
117
+ /** Normalize DingTalk picture and richText callbacks into lazy image references. */
118
+ export function dingtalkInboundMessage(message, {
119
+ api,
120
+ clientId,
121
+ clientSecret,
122
+ } = {}) {
123
+ const msgtype = String(message?.msgtype ?? '').toLowerCase();
124
+ const content = parsedMessageContent(message);
125
+ const richEntries = msgtype === 'richtext' ? richTextEntries(content) : [];
126
+ const text = msgtype === 'text'
127
+ ? nonEmptyString(message?.text?.content) ?? ''
128
+ : richEntries.map(richTextEntryText).filter(Boolean).join('\n');
129
+ const imageCodes = [];
130
+ if (msgtype === 'picture') {
131
+ const code = downloadCodeFor(content);
132
+ if (code) imageCodes.push(code);
133
+ } else if (msgtype === 'richtext') {
134
+ for (const entry of richEntries) {
135
+ if (String(entry?.type ?? '').toLowerCase() !== 'picture') continue;
136
+ const code = downloadCodeFor(entry);
137
+ if (code) imageCodes.push(code);
138
+ }
139
+ }
140
+ return {
141
+ content: text,
142
+ images: imageCodes.map((downloadCode, index) => ({
143
+ name: index === 0 ? 'image' : `image-${index + 1}`,
144
+ load: ({ signal, maxBytes }) => {
145
+ if (typeof api?.downloadImage !== 'function') {
146
+ throw new Error('DingTalk API does not support image downloads');
147
+ }
148
+ return api.downloadImage({
149
+ clientId,
150
+ clientSecret,
151
+ robotCode: message?.robotCode,
152
+ downloadCode,
153
+ signal,
154
+ maxBytes,
155
+ });
156
+ },
157
+ })),
158
+ };
159
+ }
160
+
42
161
  function conversationKey(message, sender) {
43
162
  if (String(message?.conversationType) === '2') {
44
163
  const conversationId = nonEmptyString(message?.conversationId);
@@ -191,6 +310,9 @@ export class DingtalkHarnessBridge {
191
310
  } catch {
192
311
  // An unsafe reply route must never be able to submit an approval.
193
312
  }
313
+ if (sessionWebhook && String(message.conversationType) !== '2') {
314
+ rememberConnectionTestTarget(this.#state, { sessionWebhook });
315
+ }
194
316
  const pending = this.#pendingInteractions.get(key);
195
317
  const approvalReply = this.#approvals.claimReply({
196
318
  key,
@@ -317,49 +439,63 @@ export class DingtalkHarnessBridge {
317
439
  return;
318
440
  }
319
441
 
320
- const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
442
+ const promptMessage = dingtalkInboundMessage(message, {
443
+ api: this.#api,
444
+ clientId: this.#clientId,
445
+ clientSecret: this.#clientSecret,
446
+ });
447
+ const text = promptMessage.content;
448
+ const hasImages = hasInboundImages(promptMessage);
449
+ const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
321
450
  let cardStream = null;
322
451
  let cardStarted = false;
323
452
  try {
324
- if (!text) {
325
- await this.#send(sessionWebhook, '目前仅支持文字消息。');
453
+ if (!text && !hasImages) {
454
+ await this.#send(sessionWebhook, '目前支持文字和图片消息。');
326
455
  return;
327
456
  }
328
457
 
329
458
  const command = text.toLowerCase();
330
- if (command === '/help') {
459
+ if (isPlainText && !hasImages && command === '/help') {
331
460
  await this.#send(sessionWebhook, HELP_TEXT);
332
461
  return;
333
462
  }
334
- if (command === '/status') {
463
+ if (isPlainText && !hasImages && command === '/status') {
335
464
  await this.#harness.ensureRunning({ signal: this.#signal });
336
465
  await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
337
466
  return;
338
467
  }
339
- if (command === '/new') {
468
+ if (isPlainText && !hasImages && command === '/new') {
340
469
  await this.#state.clearSession(key);
341
470
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
342
471
  return;
343
472
  }
344
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
473
+ const workspaceCommand = isPlainText && !hasImages
474
+ ? await runWorkspaceCommand(text, this.#harness, key)
475
+ : null;
345
476
  if (workspaceCommand) {
346
477
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
347
478
  await this.#send(sessionWebhook, reply);
348
479
  }
349
480
  return;
350
481
  }
351
- const compactCommand = await runCompactCommand(
352
- text,
353
- this.#harness,
354
- this.#state,
355
- key,
356
- { signal: this.#signal },
357
- );
482
+ const compactCommand = isPlainText && !hasImages
483
+ ? await runCompactCommand(
484
+ text,
485
+ this.#harness,
486
+ this.#state,
487
+ key,
488
+ { signal: this.#signal },
489
+ )
490
+ : null;
358
491
  if (compactCommand) {
359
492
  await this.#send(sessionWebhook, compactCommand.message);
360
493
  return;
361
494
  }
362
495
 
496
+ const content = hasImages
497
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
498
+ : undefined;
363
499
  if (typeof this.#api.createAiCard === 'function'
364
500
  && typeof this.#api.updateAiCard === 'function'
365
501
  && typeof this.#api.finishAiCard === 'function') {
@@ -377,7 +513,7 @@ export class DingtalkHarnessBridge {
377
513
  harness: this.#harness,
378
514
  state: this.#state,
379
515
  key,
380
- text,
516
+ ...(hasImages ? { content } : { text }),
381
517
  createOptions: { signal: this.#signal },
382
518
  existsOptions: { signal: this.#signal },
383
519
  askOptions: {
@@ -400,13 +536,17 @@ export class DingtalkHarnessBridge {
400
536
  increment(this.#status, 'messagesReplied');
401
537
  this.#status.lastReplyAt = new Date().toISOString();
402
538
  this.#status.lastError = null;
403
- } catch {
539
+ } catch (error) {
404
540
  if (this.#signal?.aborted) return;
405
541
  this.#status.lastError = '钉钉消息处理失败。';
406
- this.#logger.error?.('[dsh-dingtalk] failed to process an inbound message');
542
+ this.#logger.error?.(
543
+ '[dsh-dingtalk] failed to process an inbound message',
544
+ safeErrorDiagnostic(error),
545
+ );
407
546
  try {
408
- const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT);
409
- if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT);
547
+ const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
548
+ const streamed = cardStarted && await cardStream.finish(errorText);
549
+ if (!streamed) await this.#send(sessionWebhook, errorText);
410
550
  } catch {
411
551
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
412
552
  }
@@ -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);