@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.
Files changed (52) hide show
  1. package/README.en.md +23 -3
  2. package/README.md +23 -3
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +815 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +152 -23
  21. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  22. package/plugin-src/client/i18n.js +20 -0
  23. package/plugin-src/client/styles.js +23 -8
  24. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  25. package/plugin-src/host/index.mjs +14 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  28. package/src/channels/discord/discord-api.mjs +134 -6
  29. package/src/channels/discord/discord-runtime.mjs +15 -4
  30. package/src/channels/feishu/bridge.mjs +223 -15
  31. package/src/channels/feishu/feishu-channel.mjs +227 -1
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +217 -10
  34. package/src/channels/shared/editable-message-stream.mjs +18 -1
  35. package/src/channels/shared/harness-client.mjs +99 -7
  36. package/src/channels/shared/semantic/artifact.mjs +748 -0
  37. package/src/channels/shared/semantic/delivery.mjs +153 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  39. package/src/channels/shared/workspace-session.mjs +15 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +167 -4
  42. package/src/channels/slack/slack-runtime.mjs +21 -5
  43. package/src/channels/telegram/telegram-api.mjs +111 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  45. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  46. package/src/channels/weixin/weixin-api.mjs +268 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  48. package/src/channels/weixin/weixin-controller.mjs +5 -1
  49. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  50. package/src/channels/whatsapp/config-store.mjs +43 -0
  51. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  52. 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 = nonEmptyString(value);
29
- return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
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 streamed = cardStarted && await cardStream.finish(answer);
623
- if (!streamed) await this.#send(sessionWebhook, answer);
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({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
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.2)',
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) throw new Error(`Discord ${method} returned invalid JSON`);
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, { method, body, signal, timeoutMs, expectBody, retry: false });
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;