@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
|
@@ -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(
|
|
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)
|
|
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
|
|
77
|
+
answer,
|
|
78
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
65
79
|
};
|
|
66
80
|
} catch (error) {
|
|
67
81
|
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
|
@@ -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({
|
|
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 ? {} : {
|
|
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)
|
|
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
|
}
|