@xmanrui/dsh-im 1.0.2 → 1.2.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 +23 -3
- package/README.md +23 -3
- package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
- package/assets/logo_cn.png +0 -0
- package/lib/client.js +815 -560
- package/lib/index.js +163 -163
- package/package.json +1 -1
- package/plugin-src/client/agent-preset.js +15 -6
- package/plugin-src/client/channel-card-meta.js +48 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -19
- package/plugin-src/client/channels/dingtalk/styles.js +0 -6
- package/plugin-src/client/channels/feishu/index.js +41 -35
- package/plugin-src/client/channels/feishu/styles.js +0 -5
- package/plugin-src/client/channels/qq/index.js +24 -16
- package/plugin-src/client/channels/shared/token-channel.js +32 -24
- package/plugin-src/client/channels/wecom/index.js +24 -16
- package/plugin-src/client/channels/weixin/index.js +29 -23
- package/plugin-src/client/channels/weixin/styles.js +0 -5
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +152 -23
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +20 -0
- package/plugin-src/client/styles.js +23 -8
- package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
- package/plugin-src/host/index.mjs +14 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
- package/src/channels/discord/discord-api.mjs +134 -6
- package/src/channels/discord/discord-runtime.mjs +15 -4
- package/src/channels/feishu/bridge.mjs +223 -15
- package/src/channels/feishu/feishu-channel.mjs +227 -1
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +217 -10
- package/src/channels/shared/editable-message-stream.mjs +18 -1
- package/src/channels/shared/harness-client.mjs +99 -7
- package/src/channels/shared/semantic/artifact.mjs +748 -0
- package/src/channels/shared/semantic/delivery.mjs +153 -0
- package/src/channels/shared/text-harness-bridge.mjs +149 -3
- package/src/channels/shared/workspace-session.mjs +15 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +167 -4
- package/src/channels/slack/slack-runtime.mjs +21 -5
- package/src/channels/telegram/telegram-api.mjs +111 -5
- package/src/channels/telegram/telegram-runtime.mjs +18 -4
- package/src/channels/wecom/wecom-bridge.mjs +260 -12
- package/src/channels/weixin/weixin-api.mjs +268 -2
- package/src/channels/weixin/weixin-bridge.mjs +134 -3
- package/src/channels/weixin/weixin-controller.mjs +5 -1
- package/src/channels/weixin/weixin-runtime.mjs +5 -1
- package/src/channels/whatsapp/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { extname } from 'node:path';
|
|
2
3
|
|
|
3
4
|
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
|
4
5
|
|
|
@@ -25,8 +26,67 @@ function nonEmptyString(value) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function safeProviderCode(value) {
|
|
28
|
-
const code =
|
|
29
|
-
return code &&
|
|
29
|
+
const code = value === undefined || value === null ? null : String(value).trim();
|
|
30
|
+
return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function preserveArtifactMetadata(target, source) {
|
|
34
|
+
if (Number.isInteger(source?.status)) target.status = source.status;
|
|
35
|
+
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dingtalkArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
|
|
40
|
+
if (cause?.code?.startsWith?.('artifact-')) return cause;
|
|
41
|
+
const status = Number(cause?.status);
|
|
42
|
+
const providerCode = safeProviderCode(cause?.providerCode);
|
|
43
|
+
const providerText = providerCode ?? '';
|
|
44
|
+
let code = fallback;
|
|
45
|
+
let message = 'DingTalk could not prepare the file for delivery.';
|
|
46
|
+
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|
|
47
|
+
|| /(?:permission|forbidden|unauthor|access.?denied|\.auth(?:\.|$))/i.test(providerText)) {
|
|
48
|
+
code = 'artifact-permission-required';
|
|
49
|
+
message = 'DingTalk denied permission to send the file.';
|
|
50
|
+
} else if (status === 413 || providerCode === '413'
|
|
51
|
+
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
|
|
52
|
+
code = 'artifact-too-large';
|
|
53
|
+
message = 'The file exceeds DingTalk\'s size limit.';
|
|
54
|
+
} else if (status === 429 || providerCode === '429'
|
|
55
|
+
|| /(?:rate.?limit|too.?many|throttl)/i.test(providerText)) {
|
|
56
|
+
code = 'artifact-rate-limited';
|
|
57
|
+
message = 'DingTalk rate-limited file delivery.';
|
|
58
|
+
} else if (fallback === 'artifact-provider-rejected') {
|
|
59
|
+
message = 'DingTalk rejected the file message.';
|
|
60
|
+
}
|
|
61
|
+
const error = new Error(message, { cause });
|
|
62
|
+
error.code = code;
|
|
63
|
+
return preserveArtifactMetadata(error, cause);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function uncertainDingtalkDelivery(cause) {
|
|
67
|
+
const error = new Error('DingTalk file delivery result is uncertain', { cause });
|
|
68
|
+
error.code = 'artifact-delivery-uncertain';
|
|
69
|
+
return preserveArtifactMetadata(error, cause);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rejectedProviderResponse(value) {
|
|
73
|
+
if (!value || typeof value !== 'object') return null;
|
|
74
|
+
for (const field of ['errcode', 'code']) {
|
|
75
|
+
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
|
|
76
|
+
return safeProviderCode(value[field]) ?? 'rejected';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function classifyDingtalkFinalDeliveryError(error, signal) {
|
|
83
|
+
if (signal?.aborted) throw abortError(signal);
|
|
84
|
+
const status = Number(error?.status);
|
|
85
|
+
if (error?.code === 'network-error' || error?.code === 'timeout'
|
|
86
|
+
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
|
|
87
|
+
return uncertainDingtalkDelivery(error);
|
|
88
|
+
}
|
|
89
|
+
return dingtalkArtifactError(error);
|
|
30
90
|
}
|
|
31
91
|
|
|
32
92
|
function secureDingtalkDownloadUrl(value) {
|
|
@@ -174,6 +234,74 @@ async function requestJson(fetchImpl, url, {
|
|
|
174
234
|
}
|
|
175
235
|
}
|
|
176
236
|
|
|
237
|
+
async function requestMultipart(fetchImpl, url, { body, signal, timeoutMs = 60_000 } = {}) {
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
let timedOut = false;
|
|
240
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
241
|
+
if (signal?.aborted) throw abortError(signal);
|
|
242
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
timedOut = true;
|
|
245
|
+
controller.abort();
|
|
246
|
+
}, timeoutMs);
|
|
247
|
+
try {
|
|
248
|
+
const response = await fetchImpl(url, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
body,
|
|
251
|
+
signal: controller.signal,
|
|
252
|
+
redirect: 'error',
|
|
253
|
+
});
|
|
254
|
+
let value;
|
|
255
|
+
let parseError;
|
|
256
|
+
try {
|
|
257
|
+
value = await response.json();
|
|
258
|
+
} catch (error) {
|
|
259
|
+
parseError = error;
|
|
260
|
+
}
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
throw new DingtalkApiError(
|
|
263
|
+
'http-error',
|
|
264
|
+
`钉钉服务请求失败(HTTP ${response.status})。`,
|
|
265
|
+
{ status: response.status, providerCode: safeProviderCode(value?.code ?? value?.errcode) },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (parseError) {
|
|
269
|
+
throw new DingtalkApiError(
|
|
270
|
+
'invalid-response',
|
|
271
|
+
'钉钉服务返回了无法解析的响应。',
|
|
272
|
+
{ cause: parseError },
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (signal?.aborted) throw abortError(signal);
|
|
278
|
+
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
|
|
279
|
+
if (error instanceof DingtalkApiError) throw error;
|
|
280
|
+
throw new DingtalkApiError('network-error', '暂时无法完成钉钉文件上传请求。', { cause: error });
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
signal?.removeEventListener('abort', onAbort);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeFileTarget(target) {
|
|
288
|
+
const robotCode = nonEmptyString(target?.robotCode);
|
|
289
|
+
if (!robotCode) throw new TypeError('DingTalk robotCode is required');
|
|
290
|
+
if (target?.type === 'group') {
|
|
291
|
+
const openConversationId = nonEmptyString(target.openConversationId);
|
|
292
|
+
if (openConversationId) return { type: 'group', robotCode, openConversationId };
|
|
293
|
+
}
|
|
294
|
+
if (target?.type === 'user') {
|
|
295
|
+
const userId = nonEmptyString(target.userId);
|
|
296
|
+
if (userId) return { type: 'user', robotCode, userId };
|
|
297
|
+
}
|
|
298
|
+
throw new TypeError('DingTalk file target is invalid');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function dingtalkFileType(fileName) {
|
|
302
|
+
return extname(fileName).slice(1).toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
|
|
177
305
|
function normalizeCardTarget(target) {
|
|
178
306
|
if (target?.type === 'user') {
|
|
179
307
|
const userId = nonEmptyString(target.userId);
|
|
@@ -648,6 +776,91 @@ export function createDingtalkApi({
|
|
|
648
776
|
return true;
|
|
649
777
|
},
|
|
650
778
|
|
|
779
|
+
async sendFile({ clientId, clientSecret, target, file, signal }) {
|
|
780
|
+
if (!file || typeof file !== 'object'
|
|
781
|
+
|| typeof file.fileName !== 'string' || !file.fileName
|
|
782
|
+
|| !Buffer.isBuffer(file.bytes)) {
|
|
783
|
+
throw new TypeError('A DingTalk file is required');
|
|
784
|
+
}
|
|
785
|
+
const normalizedTarget = normalizeFileTarget(target);
|
|
786
|
+
const fileType = dingtalkFileType(file.fileName);
|
|
787
|
+
let token;
|
|
788
|
+
try {
|
|
789
|
+
token = await accessToken({ clientId, clientSecret, signal });
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (signal?.aborted) throw abortError(signal);
|
|
792
|
+
const status = Number(error?.status);
|
|
793
|
+
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
|
794
|
+
? 'artifact-provider-rejected'
|
|
795
|
+
: 'artifact-provider-failed';
|
|
796
|
+
throw dingtalkArtifactError(error, { fallback });
|
|
797
|
+
}
|
|
798
|
+
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
|
799
|
+
uploadUrl.searchParams.set('access_token', token);
|
|
800
|
+
uploadUrl.searchParams.set('type', 'file');
|
|
801
|
+
const form = new FormData();
|
|
802
|
+
form.append(
|
|
803
|
+
'media',
|
|
804
|
+
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
|
805
|
+
file.fileName,
|
|
806
|
+
);
|
|
807
|
+
let uploaded;
|
|
808
|
+
try {
|
|
809
|
+
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (signal?.aborted) throw abortError(signal);
|
|
812
|
+
const status = Number(error?.status);
|
|
813
|
+
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
|
814
|
+
? 'artifact-provider-rejected'
|
|
815
|
+
: 'artifact-provider-failed';
|
|
816
|
+
throw dingtalkArtifactError(error, { fallback });
|
|
817
|
+
}
|
|
818
|
+
const uploadRejection = rejectedProviderResponse(uploaded);
|
|
819
|
+
if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
|
|
820
|
+
throw dingtalkArtifactError(new DingtalkApiError(
|
|
821
|
+
'upload-rejected',
|
|
822
|
+
'钉钉服务拒绝了文件上传。',
|
|
823
|
+
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
|
824
|
+
));
|
|
825
|
+
}
|
|
826
|
+
signal?.throwIfAborted();
|
|
827
|
+
const messageBody = {
|
|
828
|
+
robotCode: normalizedTarget.robotCode,
|
|
829
|
+
msgKey: 'sampleFile',
|
|
830
|
+
msgParam: JSON.stringify({
|
|
831
|
+
mediaId: uploaded.media_id,
|
|
832
|
+
fileName: file.fileName,
|
|
833
|
+
fileType,
|
|
834
|
+
}),
|
|
835
|
+
...(normalizedTarget.type === 'group'
|
|
836
|
+
? { openConversationId: normalizedTarget.openConversationId }
|
|
837
|
+
: { userIds: [normalizedTarget.userId] }),
|
|
838
|
+
};
|
|
839
|
+
const pathname = normalizedTarget.type === 'group'
|
|
840
|
+
? 'v1.0/robot/groupMessages/send'
|
|
841
|
+
: 'v1.0/robot/oToMessages/batchSend';
|
|
842
|
+
let response;
|
|
843
|
+
try {
|
|
844
|
+
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
|
845
|
+
body: messageBody,
|
|
846
|
+
headers: { 'x-acs-dingtalk-access-token': token },
|
|
847
|
+
signal,
|
|
848
|
+
action: '文件消息发送',
|
|
849
|
+
});
|
|
850
|
+
} catch (error) {
|
|
851
|
+
throw classifyDingtalkFinalDeliveryError(error, signal);
|
|
852
|
+
}
|
|
853
|
+
const sendRejection = rejectedProviderResponse(response);
|
|
854
|
+
if (sendRejection) {
|
|
855
|
+
throw dingtalkArtifactError(new DingtalkApiError(
|
|
856
|
+
'send-rejected',
|
|
857
|
+
'钉钉服务拒绝了文件消息。',
|
|
858
|
+
{ providerCode: sendRejection },
|
|
859
|
+
));
|
|
860
|
+
}
|
|
861
|
+
return response;
|
|
862
|
+
},
|
|
863
|
+
|
|
651
864
|
clearAccessToken(clientId) {
|
|
652
865
|
const appKey = nonEmptyString(clientId);
|
|
653
866
|
if (appKey) tokenCache.delete(appKey);
|
|
@@ -30,6 +30,16 @@ import {
|
|
|
30
30
|
promptContentForMessage,
|
|
31
31
|
} from '../shared/image-prompt.mjs';
|
|
32
32
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
33
|
+
import {
|
|
34
|
+
materializeOutboundArtifact,
|
|
35
|
+
releaseOutboundArtifact,
|
|
36
|
+
} from '../shared/semantic/artifact.mjs';
|
|
37
|
+
import {
|
|
38
|
+
createArtifactFailureReceipt,
|
|
39
|
+
createDeliveryReceipt,
|
|
40
|
+
mergeDeliveryReceipts,
|
|
41
|
+
providerMessageIdsFor,
|
|
42
|
+
} from '../shared/semantic/delivery.mjs';
|
|
33
43
|
|
|
34
44
|
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
|
35
45
|
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
|
|
@@ -62,6 +72,13 @@ function nonEmptyString(value) {
|
|
|
62
72
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
function dingtalkFileProviderIds(result) {
|
|
76
|
+
const ids = providerMessageIdsFor(result);
|
|
77
|
+
const processQueryKey = nonEmptyString(result?.processQueryKey);
|
|
78
|
+
if (processQueryKey && !ids.includes(processQueryKey)) ids.push(processQueryKey);
|
|
79
|
+
return ids;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
function safeErrorDiagnostic(error) {
|
|
66
83
|
const chain = [];
|
|
67
84
|
const seen = new Set();
|
|
@@ -195,6 +212,40 @@ function cardTarget(message, sender) {
|
|
|
195
212
|
return { type: 'user', userId: sender };
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
function fileTarget(message, sender, clientId) {
|
|
216
|
+
const robotCode = nonEmptyString(message?.robotCode) ?? clientId;
|
|
217
|
+
if (String(message?.conversationType) === '2') {
|
|
218
|
+
return {
|
|
219
|
+
type: 'group',
|
|
220
|
+
openConversationId: nonEmptyString(message?.conversationId),
|
|
221
|
+
robotCode,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { type: 'user', userId: sender, robotCode };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function artifactFailureText(fileName, error) {
|
|
228
|
+
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
|
229
|
+
switch (error?.code) {
|
|
230
|
+
case 'artifact-delivery-uncertain':
|
|
231
|
+
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
|
232
|
+
case 'artifact-permission-required':
|
|
233
|
+
return `结果文件「${name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
|
|
234
|
+
case 'artifact-too-large':
|
|
235
|
+
return `结果文件「${name}」超过当前钉钉机器人可发送的文件大小,未发送。`;
|
|
236
|
+
case 'artifact-rate-limited':
|
|
237
|
+
return `结果文件「${name}」暂时被钉钉限流,未能发送,请稍后重试。`;
|
|
238
|
+
case 'artifact-provider-rejected':
|
|
239
|
+
return `结果文件「${name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。`;
|
|
240
|
+
case 'artifact-invalid':
|
|
241
|
+
case 'artifact-changed':
|
|
242
|
+
case 'artifact-unavailable':
|
|
243
|
+
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
|
244
|
+
default:
|
|
245
|
+
return `结果文件「${name}」已生成,但暂时未能通过钉钉发送,请稍后重试。`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
198
249
|
function progressText(update) {
|
|
199
250
|
if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
|
|
200
251
|
if (update?.type === 'tool') {
|
|
@@ -596,7 +647,7 @@ export class DingtalkHarnessBridge {
|
|
|
596
647
|
});
|
|
597
648
|
cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
|
|
598
649
|
}
|
|
599
|
-
const { answer } = await askInWorkspaceSession({
|
|
650
|
+
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
600
651
|
harness: this.#harness,
|
|
601
652
|
state: this.#state,
|
|
602
653
|
key,
|
|
@@ -619,11 +670,41 @@ export class DingtalkHarnessBridge {
|
|
|
619
670
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
620
671
|
},
|
|
621
672
|
});
|
|
622
|
-
const
|
|
623
|
-
|
|
673
|
+
const answerText = typeof answer === 'string' && answer.trim()
|
|
674
|
+
? answer
|
|
675
|
+
: artifacts.length > 0 ? '结果文件已生成。' : answer;
|
|
676
|
+
let textDeliveryError = null;
|
|
677
|
+
let textReceipt = null;
|
|
678
|
+
let streamed = false;
|
|
679
|
+
try {
|
|
680
|
+
streamed = cardStarted && await cardStream.finish(answerText);
|
|
681
|
+
if (streamed) {
|
|
682
|
+
textReceipt = createDeliveryReceipt({
|
|
683
|
+
deliveryId: messageId,
|
|
684
|
+
presentation: 'dingtalk-card',
|
|
685
|
+
});
|
|
686
|
+
} else {
|
|
687
|
+
textReceipt = createDeliveryReceipt({
|
|
688
|
+
deliveryId: messageId,
|
|
689
|
+
presentation: 'dingtalk-text',
|
|
690
|
+
providerMessageIds: await this.#send(sessionWebhook, answerText),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
textDeliveryError = error;
|
|
695
|
+
}
|
|
696
|
+
const delivery = await this.#deliverArtifacts(
|
|
697
|
+
fileTarget(message, sender, this.#clientId),
|
|
698
|
+
sessionWebhook,
|
|
699
|
+
messageId,
|
|
700
|
+
artifacts,
|
|
701
|
+
textReceipt,
|
|
702
|
+
);
|
|
703
|
+
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
|
|
624
704
|
increment(this.#status, 'messagesReplied');
|
|
625
705
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
626
706
|
this.#status.lastError = null;
|
|
707
|
+
return delivery.receipt;
|
|
627
708
|
} catch (error) {
|
|
628
709
|
if (error?.code === 'turn-stopped') {
|
|
629
710
|
if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
|
|
@@ -940,16 +1021,86 @@ export class DingtalkHarnessBridge {
|
|
|
940
1021
|
}
|
|
941
1022
|
|
|
942
1023
|
async #send(sessionWebhook, text) {
|
|
1024
|
+
const providerMessageIds = [];
|
|
943
1025
|
for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
|
|
944
1026
|
this.#signal?.throwIfAborted();
|
|
945
|
-
await this.#api.sendText({
|
|
1027
|
+
const result = await this.#api.sendText({
|
|
946
1028
|
clientId: this.#clientId,
|
|
947
1029
|
clientSecret: this.#clientSecret,
|
|
948
1030
|
sessionWebhook,
|
|
949
1031
|
text: chunk,
|
|
950
1032
|
signal: this.#signal,
|
|
951
1033
|
});
|
|
1034
|
+
providerMessageIds.push(...providerMessageIdsFor(result));
|
|
1035
|
+
}
|
|
1036
|
+
return providerMessageIds;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
|
|
1040
|
+
const receipts = baseReceipt ? [baseReceipt] : [];
|
|
1041
|
+
let userVisible = Boolean(baseReceipt);
|
|
1042
|
+
for (const artifact of artifacts) {
|
|
1043
|
+
this.#signal?.throwIfAborted();
|
|
1044
|
+
try {
|
|
1045
|
+
if (typeof this.#api.sendFile !== 'function') {
|
|
1046
|
+
const unavailable = new Error('DingTalk file delivery is unavailable');
|
|
1047
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
1048
|
+
throw unavailable;
|
|
1049
|
+
}
|
|
1050
|
+
const file = await materializeOutboundArtifact(artifact, {
|
|
1051
|
+
signal: this.#signal,
|
|
1052
|
+
});
|
|
1053
|
+
const result = await this.#api.sendFile({
|
|
1054
|
+
clientId: this.#clientId,
|
|
1055
|
+
clientSecret: this.#clientSecret,
|
|
1056
|
+
target,
|
|
1057
|
+
file,
|
|
1058
|
+
signal: this.#signal,
|
|
1059
|
+
});
|
|
1060
|
+
receipts.push(createDeliveryReceipt({
|
|
1061
|
+
deliveryId: file.deliveryKey,
|
|
1062
|
+
presentation: 'dingtalk-file',
|
|
1063
|
+
providerMessageIds: dingtalkFileProviderIds(result),
|
|
1064
|
+
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
|
1065
|
+
}));
|
|
1066
|
+
userVisible = true;
|
|
1067
|
+
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
if (this.#signal?.aborted) throw error;
|
|
1070
|
+
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
|
1071
|
+
this.#logger.warn?.(
|
|
1072
|
+
`[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
|
|
1073
|
+
);
|
|
1074
|
+
let noticeSent = false;
|
|
1075
|
+
const providerMessageIds = await this.#send(
|
|
1076
|
+
sessionWebhook,
|
|
1077
|
+
artifactFailureText(artifact?.fileName, error),
|
|
1078
|
+
).then((ids) => {
|
|
1079
|
+
noticeSent = true;
|
|
1080
|
+
return ids;
|
|
1081
|
+
}).catch(() => []);
|
|
1082
|
+
const failureReceipt = createArtifactFailureReceipt({
|
|
1083
|
+
artifactId: artifact?.artifactId ?? 'unknown',
|
|
1084
|
+
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
|
1085
|
+
error,
|
|
1086
|
+
providerMessageIds,
|
|
1087
|
+
});
|
|
1088
|
+
receipts.push(failureReceipt);
|
|
1089
|
+
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
|
1090
|
+
} finally {
|
|
1091
|
+
releaseOutboundArtifact(artifact);
|
|
1092
|
+
}
|
|
952
1093
|
}
|
|
1094
|
+
const receipt = receipts.length === 0
|
|
1095
|
+
? null
|
|
1096
|
+
: receipts.length === 1
|
|
1097
|
+
? receipts[0]
|
|
1098
|
+
: mergeDeliveryReceipts({
|
|
1099
|
+
deliveryId: replyTo,
|
|
1100
|
+
presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
|
1101
|
+
receipts,
|
|
1102
|
+
});
|
|
1103
|
+
return { receipt, userVisible };
|
|
953
1104
|
}
|
|
954
1105
|
}
|
|
955
1106
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
|
4
|
+
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
5
|
+
const DISCORD_PERMISSION_ERRORS = new Set([50001, 50013]);
|
|
6
|
+
const DISCORD_TOO_LARGE_ERRORS = new Set([40005]);
|
|
2
7
|
|
|
3
8
|
function cleanString(value) {
|
|
4
9
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -9,6 +14,58 @@ function requestSignal(signal, timeoutMs) {
|
|
|
9
14
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
function abortReason(signal) {
|
|
18
|
+
return signal?.reason instanceof Error
|
|
19
|
+
? signal.reason
|
|
20
|
+
: new DOMException('The operation was aborted', 'AbortError');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function positiveTimeout(value, name) {
|
|
24
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function preserveProviderMetadata(target, source) {
|
|
29
|
+
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
|
30
|
+
if (source?.retry_after !== undefined) {
|
|
31
|
+
target.retry_after = source.retry_after;
|
|
32
|
+
target.retryAfter = source.retry_after;
|
|
33
|
+
}
|
|
34
|
+
if (Number.isInteger(source?.status)) target.status = source.status;
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function discordArtifactProviderError(cause) {
|
|
39
|
+
const providerCode = Number(cause?.providerCode);
|
|
40
|
+
const status = Number(cause?.status);
|
|
41
|
+
const message = cleanString(cause?.message) ?? '';
|
|
42
|
+
let code = 'artifact-provider-rejected';
|
|
43
|
+
let summary = 'Discord rejected the attachment.';
|
|
44
|
+
if (status === 401 || status === 403 || DISCORD_PERMISSION_ERRORS.has(providerCode)) {
|
|
45
|
+
code = 'artifact-permission-required';
|
|
46
|
+
summary = 'Discord denied permission to send the attachment.';
|
|
47
|
+
} else if (status === 413 || DISCORD_TOO_LARGE_ERRORS.has(providerCode)
|
|
48
|
+
|| /(?:request|attachment|file).{0,24}too large/i.test(message)) {
|
|
49
|
+
code = 'artifact-too-large';
|
|
50
|
+
summary = 'The attachment exceeds Discord\'s size limit.';
|
|
51
|
+
} else if (status === 429) {
|
|
52
|
+
code = 'artifact-rate-limited';
|
|
53
|
+
summary = 'Discord rate-limited attachment delivery.';
|
|
54
|
+
} else if (status >= 500) {
|
|
55
|
+
code = 'artifact-delivery-uncertain';
|
|
56
|
+
summary = 'Discord attachment delivery result is uncertain.';
|
|
57
|
+
}
|
|
58
|
+
const error = new Error(summary, { cause });
|
|
59
|
+
error.code = code;
|
|
60
|
+
return preserveProviderMetadata(error, cause);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uncertainDiscordDelivery(cause) {
|
|
64
|
+
const error = new Error('Discord attachment delivery result is uncertain', { cause });
|
|
65
|
+
error.code = 'artifact-delivery-uncertain';
|
|
66
|
+
return preserveProviderMetadata(error, cause);
|
|
67
|
+
}
|
|
68
|
+
|
|
12
69
|
function delay(ms, signal) {
|
|
13
70
|
return new Promise((resolve, reject) => {
|
|
14
71
|
if (signal?.aborted) {
|
|
@@ -39,13 +96,20 @@ export class DiscordApi {
|
|
|
39
96
|
#token;
|
|
40
97
|
#fetch;
|
|
41
98
|
#baseUrl;
|
|
99
|
+
#fileUploadTimeoutMs;
|
|
42
100
|
|
|
43
|
-
constructor({
|
|
101
|
+
constructor({
|
|
102
|
+
token,
|
|
103
|
+
fetchImpl = fetch,
|
|
104
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
105
|
+
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
|
106
|
+
}) {
|
|
44
107
|
if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
|
|
45
108
|
if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
|
|
46
109
|
this.#token = token.trim();
|
|
47
110
|
this.#fetch = fetchImpl;
|
|
48
111
|
this.#baseUrl = new URL(baseUrl);
|
|
112
|
+
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
|
49
113
|
}
|
|
50
114
|
|
|
51
115
|
getCurrentUser(options = {}) {
|
|
@@ -74,6 +138,54 @@ export class DiscordApi {
|
|
|
74
138
|
});
|
|
75
139
|
}
|
|
76
140
|
|
|
141
|
+
async createFileMessage({ channelId, file, replyToMessageId, signal }) {
|
|
142
|
+
if (!file || typeof file !== 'object'
|
|
143
|
+
|| typeof file.fileName !== 'string' || !file.fileName
|
|
144
|
+
|| !Buffer.isBuffer(file.bytes)) {
|
|
145
|
+
throw new TypeError('A Discord attachment is required');
|
|
146
|
+
}
|
|
147
|
+
const deliverySeed = cleanString(file.deliveryKey) ?? cleanString(file.artifactId);
|
|
148
|
+
const nonce = deliverySeed
|
|
149
|
+
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 25)
|
|
150
|
+
: undefined;
|
|
151
|
+
const payload = new FormData();
|
|
152
|
+
payload.append('payload_json', JSON.stringify({
|
|
153
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
154
|
+
attachments: [{ id: 0, filename: file.fileName }],
|
|
155
|
+
...(nonce ? { nonce, enforce_nonce: true } : {}),
|
|
156
|
+
...(replyToMessageId ? {
|
|
157
|
+
message_reference: {
|
|
158
|
+
message_id: snowflake(replyToMessageId, 'message id'),
|
|
159
|
+
channel_id: snowflake(channelId, 'channel id'),
|
|
160
|
+
fail_if_not_exists: false,
|
|
161
|
+
},
|
|
162
|
+
} : {}),
|
|
163
|
+
}));
|
|
164
|
+
payload.append(
|
|
165
|
+
'files[0]',
|
|
166
|
+
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
|
167
|
+
file.fileName,
|
|
168
|
+
);
|
|
169
|
+
const targetChannelId = snowflake(channelId, 'channel id');
|
|
170
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
171
|
+
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
|
172
|
+
try {
|
|
173
|
+
return await this.#request(`channels/${targetChannelId}/messages`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
signal: uploadSignal,
|
|
176
|
+
timeoutMs: this.#fileUploadTimeoutMs,
|
|
177
|
+
body: payload,
|
|
178
|
+
multipart: true,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
182
|
+
if (error?.code?.startsWith?.('discord-')) {
|
|
183
|
+
throw discordArtifactProviderError(error);
|
|
184
|
+
}
|
|
185
|
+
throw uncertainDiscordDelivery(error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
77
189
|
editMessage({ channelId, messageId, content, signal }) {
|
|
78
190
|
return this.#request(
|
|
79
191
|
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
|
@@ -100,6 +212,7 @@ export class DiscordApi {
|
|
|
100
212
|
timeoutMs = 15_000,
|
|
101
213
|
expectBody = true,
|
|
102
214
|
retry = true,
|
|
215
|
+
multipart = false,
|
|
103
216
|
}) {
|
|
104
217
|
let response;
|
|
105
218
|
try {
|
|
@@ -107,10 +220,10 @@ export class DiscordApi {
|
|
|
107
220
|
method,
|
|
108
221
|
headers: {
|
|
109
222
|
authorization: `Bot ${this.#token}`,
|
|
110
|
-
'content-type': 'application/json',
|
|
111
|
-
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.0
|
|
223
|
+
...(multipart ? {} : { 'content-type': 'application/json' }),
|
|
224
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.1.0)',
|
|
112
225
|
},
|
|
113
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
226
|
+
...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
|
|
114
227
|
signal: requestSignal(signal, timeoutMs),
|
|
115
228
|
redirect: 'error',
|
|
116
229
|
});
|
|
@@ -124,17 +237,32 @@ export class DiscordApi {
|
|
|
124
237
|
try {
|
|
125
238
|
parsed = await response.json();
|
|
126
239
|
} catch {
|
|
127
|
-
if (expectBody)
|
|
240
|
+
if (expectBody) {
|
|
241
|
+
const error = new Error(`Discord ${method} returned invalid JSON`);
|
|
242
|
+
error.status = response?.status;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
128
245
|
}
|
|
129
246
|
}
|
|
130
247
|
if (response.status === 429 && retry) {
|
|
131
248
|
const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
|
|
132
249
|
await delay(retryAfterMs, signal);
|
|
133
|
-
return this.#request(path, {
|
|
250
|
+
return this.#request(path, {
|
|
251
|
+
method, body, signal, timeoutMs, expectBody, retry: false, multipart,
|
|
252
|
+
});
|
|
134
253
|
}
|
|
135
254
|
if (!response.ok) {
|
|
136
255
|
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
|
|
137
256
|
error.code = `discord-${response.status}`;
|
|
257
|
+
error.status = response.status;
|
|
258
|
+
if (Number.isInteger(parsed?.code) || typeof parsed?.code === 'string') {
|
|
259
|
+
error.providerCode = parsed.code;
|
|
260
|
+
}
|
|
261
|
+
const retryAfter = Number(parsed?.retry_after);
|
|
262
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
263
|
+
error.retry_after = retryAfter;
|
|
264
|
+
error.retryAfter = retryAfter;
|
|
265
|
+
}
|
|
138
266
|
throw error;
|
|
139
267
|
}
|
|
140
268
|
return expectBody ? parsed : null;
|