@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
@@ -0,0 +1,153 @@
1
+ export const DELIVERY_RECEIPT_SCHEMA_VERSION = 1;
2
+
3
+ const ARTIFACT_OUTCOMES = new Set(['sent', 'rejected', 'failed', 'unknown']);
4
+ const REJECTED_ARTIFACT_ERRORS = new Set([
5
+ 'artifact-changed',
6
+ 'artifact-context-required',
7
+ 'artifact-empty',
8
+ 'artifact-invalid',
9
+ 'artifact-not-file',
10
+ 'artifact-permission-required',
11
+ 'artifact-provider-rejected',
12
+ 'artifact-too-large',
13
+ 'artifact-unavailable',
14
+ ]);
15
+
16
+ export function providerMessageIdsFor(value) {
17
+ if (!value || typeof value !== 'object') return [];
18
+ const ids = Array.isArray(value.providerMessageIds)
19
+ ? value.providerMessageIds
20
+ .filter((candidate) => (
21
+ (typeof candidate === 'string' && candidate.trim())
22
+ || Number.isSafeInteger(candidate)
23
+ ))
24
+ .map(String)
25
+ : [];
26
+ const candidates = [
27
+ value.message_id,
28
+ value.messageId,
29
+ value.id,
30
+ value.ts,
31
+ value.message?.message_id,
32
+ value.message?.messageId,
33
+ value.message?.id,
34
+ value.message?.ts,
35
+ value.key?.id,
36
+ value.data?.message_id,
37
+ ];
38
+ const id = candidates.find((candidate) => (
39
+ (typeof candidate === 'string' && candidate.trim())
40
+ || Number.isSafeInteger(candidate)
41
+ ));
42
+ if (id !== undefined) ids.push(String(id));
43
+ return [...new Set(ids)];
44
+ }
45
+
46
+ function requiredString(value, name) {
47
+ if (typeof value !== 'string' || !value.trim()) {
48
+ throw new TypeError(`${name} must be a non-empty string`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function providerIds(values) {
54
+ if (!Array.isArray(values)) throw new TypeError('providerMessageIds must be an array');
55
+ const ids = [];
56
+ const seen = new Set();
57
+ for (const value of values) {
58
+ const id = requiredString(value, 'providerMessageId');
59
+ if (seen.has(id)) continue;
60
+ seen.add(id);
61
+ ids.push(id);
62
+ }
63
+ return Object.freeze(ids);
64
+ }
65
+
66
+ function artifactResults(values) {
67
+ if (!Array.isArray(values)) throw new TypeError('artifacts must be an array');
68
+ return Object.freeze(values.map((value) => {
69
+ if (!value || typeof value !== 'object') {
70
+ throw new TypeError('artifact result must be an object');
71
+ }
72
+ const artifactId = requiredString(value.artifactId, 'artifactId');
73
+ if (!ARTIFACT_OUTCOMES.has(value.outcome)) {
74
+ throw new TypeError('artifact outcome must be sent, rejected, failed, or unknown');
75
+ }
76
+ const reason = value.reason === undefined
77
+ ? undefined
78
+ : requiredString(value.reason, 'artifact reason');
79
+ return Object.freeze({
80
+ artifactId,
81
+ outcome: value.outcome,
82
+ ...(reason === undefined ? {} : { reason }),
83
+ });
84
+ }));
85
+ }
86
+
87
+ export function artifactOutcomeForError(error) {
88
+ const code = typeof error === 'string' ? error : error?.code;
89
+ if (code === 'artifact-delivery-uncertain') return 'unknown';
90
+ if (REJECTED_ARTIFACT_ERRORS.has(code)) return 'rejected';
91
+ return 'failed';
92
+ }
93
+
94
+ export function createDeliveryReceipt({
95
+ deliveryId,
96
+ presentation,
97
+ providerMessageIds = [],
98
+ artifacts = [],
99
+ }) {
100
+ return Object.freeze({
101
+ schemaVersion: DELIVERY_RECEIPT_SCHEMA_VERSION,
102
+ deliveryId: requiredString(deliveryId, 'deliveryId'),
103
+ presentation: requiredString(presentation, 'presentation'),
104
+ providerMessageIds: providerIds(providerMessageIds),
105
+ artifacts: artifactResults(artifacts),
106
+ });
107
+ }
108
+
109
+ export function createArtifactFailureReceipt({
110
+ artifactId,
111
+ deliveryId,
112
+ error,
113
+ presentation = 'text-fallback',
114
+ providerMessageIds = [],
115
+ }) {
116
+ const code = typeof error === 'string' ? error : error?.code;
117
+ const reason = typeof code === 'string' && code
118
+ ? code
119
+ : 'artifact-provider-failed';
120
+ return createDeliveryReceipt({
121
+ deliveryId,
122
+ presentation,
123
+ providerMessageIds,
124
+ artifacts: [{
125
+ artifactId,
126
+ outcome: artifactOutcomeForError(error),
127
+ reason,
128
+ }],
129
+ });
130
+ }
131
+
132
+ export function mergeDeliveryReceipts({ deliveryId, presentation, receipts }) {
133
+ if (!Array.isArray(receipts) || receipts.length === 0) {
134
+ throw new TypeError('receipts must contain at least one delivery receipt');
135
+ }
136
+ const messageIds = [];
137
+ const artifacts = new Map();
138
+ for (const receipt of receipts) {
139
+ if (!receipt || receipt.schemaVersion !== DELIVERY_RECEIPT_SCHEMA_VERSION) {
140
+ throw new TypeError('receipt must use DeliveryReceipt schema version 1');
141
+ }
142
+ messageIds.push(...(receipt.providerMessageIds ?? []));
143
+ for (const artifact of receipt.artifacts ?? []) {
144
+ artifacts.set(artifact.artifactId, artifact);
145
+ }
146
+ }
147
+ return createDeliveryReceipt({
148
+ deliveryId,
149
+ presentation,
150
+ providerMessageIds: messageIds,
151
+ artifacts: [...artifacts.values()],
152
+ });
153
+ }
@@ -28,8 +28,19 @@ import {
28
28
  harnessQuestionText,
29
29
  validHarnessQuestion,
30
30
  } from './harness-question.mjs';
31
+ import {
32
+ materializeOutboundArtifact,
33
+ releaseOutboundArtifact,
34
+ } from './semantic/artifact.mjs';
35
+ import {
36
+ createArtifactFailureReceipt,
37
+ createDeliveryReceipt,
38
+ mergeDeliveryReceipts,
39
+ providerMessageIdsFor,
40
+ } from './semantic/delivery.mjs';
31
41
 
32
42
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
43
+ const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
33
44
 
34
45
  function cleanText(value) {
35
46
  return typeof value === 'string' ? value.trim() : '';
@@ -42,6 +53,42 @@ function canClaimInteractionReply(message, pending, senderId) {
42
53
  && Boolean(cleanText(message.content));
43
54
  }
44
55
 
56
+ function artifactFailureText(fileName, error, descriptor) {
57
+ const name = String(fileName ?? '结果文件')
58
+ .replace(/[\r\n]+/g, ' ')
59
+ .trim()
60
+ .slice(0, 255) || '结果文件';
61
+ switch (error?.code) {
62
+ case 'artifact-delivery-uncertain':
63
+ return `结果文件「${name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
64
+ case 'artifact-permission-required':
65
+ if (descriptor?.key === 'slack') {
66
+ return `结果文件「${name}」已生成,但 Slack 应用缺少 files:write 权限。请更新 Manifest、重新安装应用并重新连接机器人后重试。`;
67
+ }
68
+ if (descriptor?.key === 'discord') {
69
+ return `结果文件「${name}」已生成,但机器人缺少 Discord 的 Send Messages、Attach Files 或 Read Message History 权限。`;
70
+ }
71
+ if (descriptor?.key === 'telegram') {
72
+ return `结果文件「${name}」已生成,但 Telegram 不允许机器人在当前聊天发送文档,请检查聊天权限。`;
73
+ }
74
+ return `结果文件「${name}」已生成,但当前机器人没有文件发送权限,请检查渠道权限。`;
75
+ case 'artifact-too-large':
76
+ return `结果文件「${name}」超过当前渠道大小上限,未发送。`;
77
+ case 'artifact-empty':
78
+ return `结果文件「${name}」为空,未发送。`;
79
+ case 'artifact-invalid':
80
+ case 'artifact-changed':
81
+ case 'artifact-unavailable':
82
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
83
+ case 'artifact-rate-limited':
84
+ return `结果文件「${name}」暂时被当前渠道限流,未能发送,请稍后重试。`;
85
+ case 'artifact-provider-rejected':
86
+ return `结果文件「${name}」已生成,但当前渠道拒绝了该文件或文件消息。`;
87
+ default:
88
+ return `结果文件「${name}」已生成,但当前渠道暂时未能发送,请稍后重试。`;
89
+ }
90
+ }
91
+
45
92
  export function createTextBridgeStatus() {
46
93
  return {
47
94
  messagesReceived: 0,
@@ -285,6 +332,76 @@ export class TextHarnessBridge {
285
332
  });
286
333
  }
287
334
 
335
+ async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
336
+ const receipts = baseReceipt ? [baseReceipt] : [];
337
+ let userVisible = Boolean(baseReceipt);
338
+ for (const artifact of artifacts) {
339
+ this.#signal?.throwIfAborted();
340
+ try {
341
+ if (typeof this.#bot.sendFile !== 'function') {
342
+ const unavailable = new Error('Native file delivery is unavailable');
343
+ unavailable.code = 'artifact-provider-unavailable';
344
+ throw unavailable;
345
+ }
346
+ const file = await materializeOutboundArtifact(artifact, {
347
+ signal: this.#signal,
348
+ });
349
+ this.#signal?.throwIfAborted();
350
+ const result = await this.#bot.sendFile(target, file);
351
+ receipts.push(createDeliveryReceipt({
352
+ deliveryId: file.deliveryKey,
353
+ presentation: `${this.#descriptor.key}-file`,
354
+ providerMessageIds: providerMessageIdsFor(result),
355
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
356
+ }));
357
+ userVisible = true;
358
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
359
+ } catch (error) {
360
+ if (this.#signal?.aborted) throw error;
361
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
362
+ this.#logger.warn?.(
363
+ `[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
364
+ );
365
+ let providerMessageIds = [];
366
+ let noticeSent = false;
367
+ try {
368
+ const notice = await this.#bot.sendText(
369
+ target,
370
+ artifactFailureText(artifact?.fileName, error, this.#descriptor),
371
+ );
372
+ providerMessageIds = providerMessageIdsFor(notice);
373
+ noticeSent = true;
374
+ } catch {
375
+ this.#logger.warn?.(
376
+ `[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
377
+ );
378
+ }
379
+ const failureReceipt = createArtifactFailureReceipt({
380
+ artifactId: artifact?.artifactId ?? 'unknown',
381
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
382
+ error,
383
+ providerMessageIds,
384
+ });
385
+ receipts.push(failureReceipt);
386
+ if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
387
+ } finally {
388
+ releaseOutboundArtifact(artifact);
389
+ }
390
+ }
391
+ const receipt = receipts.length === 0
392
+ ? null
393
+ : receipts.length === 1
394
+ ? receipts[0]
395
+ : mergeDeliveryReceipts({
396
+ deliveryId: replyTo,
397
+ presentation: baseReceipt
398
+ ? `${this.#descriptor.key}-text-and-files`
399
+ : `${this.#descriptor.key}-files`,
400
+ receipts,
401
+ });
402
+ return { receipt, userVisible };
403
+ }
404
+
288
405
  async #process(message, messageId, senderId, conversationKey, {
289
406
  alreadyRecorded = false,
290
407
  } = {}) {
@@ -386,7 +503,7 @@ export class TextHarnessBridge {
386
503
  const content = hasImages
387
504
  ? await promptContentForMessage(message, { signal: this.#signal })
388
505
  : undefined;
389
- const { answer } = await askInWorkspaceSession({
506
+ const { answer, artifacts = [] } = await askInWorkspaceSession({
390
507
  harness: this.#harness,
391
508
  state: this.#state,
392
509
  key: conversationKey,
@@ -412,10 +529,23 @@ export class TextHarnessBridge {
412
529
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
413
530
  },
414
531
  });
532
+ const visibleAnswer = !cleanText(answer) && artifacts.length > 0
533
+ ? FILE_ONLY_COMPLETION_TEXT
534
+ : answer;
535
+ let textDeliveryError = null;
536
+ let textReceipt = null;
415
537
  if (stream) {
416
538
  try {
417
- await stream.finish(answer);
539
+ const result = await stream.finish(visibleAnswer);
418
540
  streamFinished = true;
541
+ textReceipt = createDeliveryReceipt({
542
+ deliveryId: messageId,
543
+ presentation: `${this.#descriptor.key}-stream`,
544
+ providerMessageIds: [
545
+ ...providerMessageIdsFor(stream),
546
+ ...providerMessageIdsFor(result),
547
+ ],
548
+ });
419
549
  } catch (error) {
420
550
  stream.cancel?.();
421
551
  this.#logger.warn?.(
@@ -424,10 +554,26 @@ export class TextHarnessBridge {
424
554
  );
425
555
  }
426
556
  }
427
- if (!streamFinished) await this.#bot.sendText(target, answer);
557
+ if (!streamFinished) {
558
+ try {
559
+ const result = await this.#bot.sendText(target, visibleAnswer);
560
+ textReceipt = createDeliveryReceipt({
561
+ deliveryId: messageId,
562
+ presentation: `${this.#descriptor.key}-text`,
563
+ providerMessageIds: providerMessageIdsFor(result),
564
+ });
565
+ } catch (error) {
566
+ textDeliveryError = error;
567
+ }
568
+ }
569
+ // A failed final text must not discard an already registered result file.
570
+ // Settle the independent attachment path before surfacing the text error.
571
+ const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
572
+ if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
428
573
  this.#status.messagesReplied += 1;
429
574
  this.#status.lastReplyAt = new Date().toISOString();
430
575
  this.#status.lastError = null;
576
+ return delivery.receipt;
431
577
  } catch (error) {
432
578
  if (error?.code === 'turn-stopped') {
433
579
  if (stream) {
@@ -59,9 +59,23 @@ export async function askInWorkspaceSession({
59
59
  return { sessionId, session };
60
60
  });
61
61
  if (!binding) continue;
62
+ const artifacts = [];
63
+ const originalOnArtifact = typeof askOptions === 'object'
64
+ && typeof askOptions?.onArtifact === 'function'
65
+ ? askOptions.onArtifact
66
+ : null;
67
+ const artifactOptions = typeof askOptions === 'number'
68
+ ? { timeoutMs: askOptions }
69
+ : { ...askOptions };
70
+ artifactOptions.onArtifact = async (artifact) => {
71
+ artifacts.push(artifact);
72
+ await originalOnArtifact?.(artifact);
73
+ };
74
+ const answer = await binding.session.ask(content ?? text, artifactOptions);
62
75
  return {
63
76
  sessionId: binding.sessionId,
64
- answer: await binding.session.ask(content ?? text, askOptions),
77
+ answer,
78
+ ...(artifacts.length > 0 ? { artifacts } : {}),
65
79
  };
66
80
  } catch (error) {
67
81
  if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
@@ -18,6 +18,7 @@ oauth_config:
18
18
  - app_mentions:read
19
19
  - chat:write
20
20
  - files:read
21
+ - files:write
21
22
  - im:history
22
23
  settings:
23
24
  event_subscriptions:
@@ -5,6 +5,8 @@ const SLACK_FILE_HOST = 'files.slack.com';
5
5
  const LEGACY_SLACK_FILE_HOST = 'slack.com';
6
6
  const SLACK_FILE_PATH_PREFIX = '/files-pri/';
7
7
  const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
8
+ const SLACK_UPLOAD_PATH_PREFIX = '/upload/';
9
+ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
8
10
 
9
11
  function cleanString(value) {
10
12
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -15,6 +17,47 @@ function requestSignal(signal, timeoutMs) {
15
17
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
16
18
  }
17
19
 
20
+ function abortReason(signal) {
21
+ return signal?.reason instanceof Error
22
+ ? signal.reason
23
+ : new DOMException('The operation was aborted', 'AbortError');
24
+ }
25
+
26
+ function positiveTimeout(value, name) {
27
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
28
+ return value;
29
+ }
30
+
31
+ function preserveProviderMetadata(target, source) {
32
+ if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
33
+ if (Number.isInteger(source?.status)) target.status = source.status;
34
+ return target;
35
+ }
36
+
37
+ function slackArtifactPreparationError(cause) {
38
+ let code = 'artifact-provider-failed';
39
+ let message = 'Slack file upload preparation failed';
40
+ if (cause?.code === 'slack-file_upload_size_restricted') {
41
+ code = 'artifact-too-large';
42
+ message = 'Slack rejected the result file because it is too large';
43
+ } else if (cause?.code === 'slack-missing-scope') {
44
+ code = 'artifact-permission-required';
45
+ message = 'Slack file delivery requires the files:write scope';
46
+ } else if (cause?.code?.startsWith?.('slack-')) {
47
+ code = 'artifact-provider-rejected';
48
+ message = 'Slack rejected file upload preparation';
49
+ }
50
+ const error = new Error(message, { cause });
51
+ error.code = code;
52
+ return preserveProviderMetadata(error, cause);
53
+ }
54
+
55
+ function uncertainSlackDelivery(cause) {
56
+ const error = new Error('Slack file completion result is uncertain', { cause });
57
+ error.code = 'artifact-delivery-uncertain';
58
+ return preserveProviderMetadata(error, cause);
59
+ }
60
+
18
61
  function isRedirectStatus(status) {
19
62
  return Number.isInteger(status) && status >= 300 && status < 400;
20
63
  }
@@ -43,6 +86,17 @@ function secureSlackFileUrl(value) {
43
86
  return url;
44
87
  }
45
88
 
89
+ function secureSlackUploadUrl(value) {
90
+ const url = new URL(value);
91
+ if (url.protocol !== 'https:' || url.username || url.password
92
+ || (url.port && url.port !== '443') || url.hostname !== SLACK_FILE_HOST
93
+ || !url.pathname.startsWith(SLACK_UPLOAD_PATH_PREFIX)) {
94
+ throw new Error('Slack returned an unsafe file upload URL');
95
+ }
96
+ url.hash = '';
97
+ return url;
98
+ }
99
+
46
100
  function redirectUrl(response, source) {
47
101
  const location = response?.headers?.get?.('location');
48
102
  if (!location) return null;
@@ -111,6 +165,7 @@ function safeOutgoingText(value, { trim = true } = {}) {
111
165
  function apiFailure(method, payload, tokenKind) {
112
166
  const reason = cleanString(payload?.error) ?? 'unknown_error';
113
167
  const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`);
168
+ error.providerCode = reason;
114
169
  if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) {
115
170
  error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token';
116
171
  } else if (reason === 'missing_scope') {
@@ -141,8 +196,15 @@ export class SlackApi {
141
196
  #fetch;
142
197
  #baseUrl;
143
198
  #botScopes = null;
199
+ #fileUploadTimeoutMs;
144
200
 
145
- constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
201
+ constructor({
202
+ botToken,
203
+ appToken,
204
+ fetchImpl = fetch,
205
+ baseUrl = DEFAULT_BASE_URL,
206
+ fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
207
+ }) {
146
208
  if (botToken !== undefined && !validSlackBotToken(botToken)) {
147
209
  throw new TypeError('Slack Bot Token is invalid');
148
210
  }
@@ -155,6 +217,7 @@ export class SlackApi {
155
217
  this.#appToken = appToken?.trim();
156
218
  this.#fetch = fetchImpl;
157
219
  this.#baseUrl = new URL(baseUrl);
220
+ this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
158
221
  }
159
222
 
160
223
  authTest(options = {}) {
@@ -239,6 +302,99 @@ export class SlackApi {
239
302
  });
240
303
  }
241
304
 
305
+ async uploadFile({ channelId, threadTs, file, signal }) {
306
+ if (!file || typeof file !== 'object'
307
+ || typeof file.fileName !== 'string' || !file.fileName
308
+ || !Buffer.isBuffer(file.bytes)) {
309
+ throw new TypeError('A Slack file is required');
310
+ }
311
+ if (signal?.aborted) throw abortReason(signal);
312
+ const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
313
+ let ticket;
314
+ try {
315
+ ticket = await this.#request('files.getUploadURLExternal', {
316
+ tokenKind: 'bot',
317
+ signal: uploadSignal,
318
+ timeoutMs: this.#fileUploadTimeoutMs,
319
+ body: { filename: file.fileName, length: file.bytes.byteLength },
320
+ });
321
+ } catch (error) {
322
+ if (signal?.aborted) throw abortReason(signal);
323
+ throw slackArtifactPreparationError(error);
324
+ }
325
+ let fileId;
326
+ let uploadUrl;
327
+ try {
328
+ fileId = requiredString(ticket?.file_id, 'file id');
329
+ uploadUrl = secureSlackUploadUrl(ticket?.upload_url);
330
+ } catch (error) {
331
+ throw slackArtifactPreparationError(error);
332
+ }
333
+ let uploaded;
334
+ try {
335
+ uploaded = await this.#fetch(uploadUrl, {
336
+ method: 'POST',
337
+ headers: { 'content-type': file.mediaType ?? 'application/octet-stream' },
338
+ body: file.bytes,
339
+ signal: uploadSignal,
340
+ redirect: 'error',
341
+ });
342
+ } catch (error) {
343
+ if (signal?.aborted) throw abortReason(signal);
344
+ throw slackArtifactPreparationError(error);
345
+ }
346
+ if (!Number.isInteger(uploaded?.status)) {
347
+ throw slackArtifactPreparationError(new Error('Slack file upload returned an invalid response'));
348
+ }
349
+ if (uploaded.status !== 200) {
350
+ await cancelResponseBody(uploaded);
351
+ const error = new Error(`Slack file upload failed with HTTP ${uploaded.status}`);
352
+ error.status = uploaded.status;
353
+ if (uploaded.status === 413) {
354
+ error.code = 'artifact-too-large';
355
+ } else if (uploaded.status === 429) {
356
+ error.code = 'artifact-rate-limited';
357
+ } else {
358
+ error.code = 'artifact-provider-rejected';
359
+ }
360
+ throw error;
361
+ }
362
+ await cancelResponseBody(uploaded);
363
+
364
+ let completionBody;
365
+ try {
366
+ completionBody = {
367
+ files: [{ id: fileId, title: file.fileName }],
368
+ channel_id: slackId(channelId, 'channel id'),
369
+ ...(threadTs ? { thread_ts: requiredString(threadTs, 'thread timestamp') } : {}),
370
+ };
371
+ } catch (error) {
372
+ throw slackArtifactPreparationError(error);
373
+ }
374
+ if (uploadSignal.aborted) {
375
+ if (signal?.aborted) throw abortReason(signal);
376
+ throw slackArtifactPreparationError(uploadSignal.reason);
377
+ }
378
+ try {
379
+ const completed = await this.#request('files.completeUploadExternal', {
380
+ tokenKind: 'bot',
381
+ signal: uploadSignal,
382
+ timeoutMs: this.#fileUploadTimeoutMs,
383
+ retry: false,
384
+ body: completionBody,
385
+ });
386
+ if (!Array.isArray(completed?.files)
387
+ || !completed.files.some((entry) => cleanString(entry?.id) === fileId)) {
388
+ throw new Error('Slack file completion did not confirm the uploaded file');
389
+ }
390
+ return completed;
391
+ } catch (error) {
392
+ if (signal?.aborted) throw abortReason(signal);
393
+ if (error?.code === 'slack-missing-scope') throw slackArtifactPreparationError(error);
394
+ throw uncertainSlackDelivery(error);
395
+ }
396
+ }
397
+
242
398
  async downloadFile({ url, signal, maxBytes }) {
243
399
  if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
244
400
  const target = secureSlackFileUrl(url);
@@ -276,18 +432,21 @@ export class SlackApi {
276
432
  }) {
277
433
  const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
278
434
  if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
435
+ const formEncoded = method === 'files.getUploadURLExternal';
279
436
  let response;
280
437
  try {
281
438
  response = await this.#fetch(new URL(method, this.#baseUrl), {
282
439
  method: 'POST',
283
440
  headers: {
284
441
  authorization: `Bearer ${token}`,
285
- 'content-type': body === undefined
442
+ 'content-type': body === undefined || formEncoded
286
443
  ? 'application/x-www-form-urlencoded;charset=utf-8'
287
444
  : 'application/json;charset=utf-8',
288
445
  'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)',
289
446
  },
290
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
447
+ ...(body === undefined ? {} : {
448
+ body: formEncoded ? new URLSearchParams(body).toString() : JSON.stringify(body),
449
+ }),
291
450
  signal: requestSignal(signal, timeoutMs),
292
451
  redirect: 'error',
293
452
  });
@@ -312,7 +471,11 @@ export class SlackApi {
312
471
  await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal);
313
472
  return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false });
314
473
  }
315
- if (!response.ok || payload?.ok !== true) throw apiFailure(method, payload, tokenKind);
474
+ if (!response.ok || payload?.ok !== true) {
475
+ const error = apiFailure(method, payload, tokenKind);
476
+ error.status = response.status;
477
+ throw error;
478
+ }
316
479
  return payload;
317
480
  }
318
481
  }