@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.
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/lib/client.js +378 -27
- package/lib/index.js +134 -131
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +54 -1
- package/src/channels/feishu/bridge.mjs +45 -18
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +227 -6
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +107 -20
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +2 -0
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/harness-client.mjs +14 -8
- package/src/channels/shared/image-prompt.mjs +268 -0
- package/src/channels/shared/text-harness-bridge.mjs +62 -16
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- package/src/channels/shared/workspace-session.mjs +2 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +95 -0
- package/src/channels/slack/slack-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +34 -3
- package/src/channels/telegram/telegram-api.mjs +37 -0
- package/src/channels/telegram/telegram-runtime.mjs +81 -9
- package/src/channels/wecom/wecom-bridge.mjs +160 -23
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +98 -2
- package/src/channels/weixin/weixin-bridge.mjs +52 -19
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- 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)
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
|
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'])
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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?.(
|
|
542
|
+
this.#logger.error?.(
|
|
543
|
+
'[dsh-dingtalk] failed to process an inbound message',
|
|
544
|
+
safeErrorDiagnostic(error),
|
|
545
|
+
);
|
|
407
546
|
try {
|
|
408
|
-
const
|
|
409
|
-
|
|
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);
|