@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
|
@@ -112,7 +112,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
class DiscordBotClient {
|
|
115
|
+
export class DiscordBotClient {
|
|
116
116
|
#api;
|
|
117
117
|
#signal;
|
|
118
118
|
|
|
@@ -123,22 +123,32 @@ class DiscordBotClient {
|
|
|
123
123
|
|
|
124
124
|
async sendText(target, text) {
|
|
125
125
|
const chunks = splitMessageText(text, 1_900);
|
|
126
|
-
|
|
126
|
+
const providerMessageIds = [];
|
|
127
127
|
for (const [index, chunk] of chunks.entries()) {
|
|
128
|
-
result = await this.#api.createMessage({
|
|
128
|
+
const result = await this.#api.createMessage({
|
|
129
129
|
channelId: target.channelId,
|
|
130
130
|
content: chunk,
|
|
131
131
|
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
|
132
132
|
signal: this.#signal,
|
|
133
133
|
});
|
|
134
|
+
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
|
|
134
135
|
}
|
|
135
|
-
return
|
|
136
|
+
return { providerMessageIds };
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
sendTyping(target) {
|
|
139
140
|
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
sendFile(target, file) {
|
|
144
|
+
return this.#api.createFileMessage({
|
|
145
|
+
channelId: target.channelId,
|
|
146
|
+
file,
|
|
147
|
+
replyToMessageId: target.replyToMessageId,
|
|
148
|
+
signal: this.#signal,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
async openStream(target) {
|
|
143
153
|
const stream = createEditableMessageStream({
|
|
144
154
|
limit: 1_900,
|
|
@@ -162,6 +172,7 @@ class DiscordBotClient {
|
|
|
162
172
|
content,
|
|
163
173
|
signal: this.#signal,
|
|
164
174
|
}),
|
|
175
|
+
messageIdForResult: (message) => message?.id,
|
|
165
176
|
});
|
|
166
177
|
return stream.start();
|
|
167
178
|
}
|
|
@@ -34,6 +34,15 @@ import {
|
|
|
34
34
|
} from '../shared/preset-command.mjs';
|
|
35
35
|
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
|
36
36
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
37
|
+
import {
|
|
38
|
+
materializeOutboundArtifact,
|
|
39
|
+
releaseOutboundArtifact,
|
|
40
|
+
} from '../shared/semantic/artifact.mjs';
|
|
41
|
+
import {
|
|
42
|
+
createArtifactFailureReceipt,
|
|
43
|
+
createDeliveryReceipt,
|
|
44
|
+
mergeDeliveryReceipts,
|
|
45
|
+
} from '../shared/semantic/delivery.mjs';
|
|
37
46
|
import {
|
|
38
47
|
MENU_PAGE_SIZE,
|
|
39
48
|
completionCard,
|
|
@@ -126,6 +135,33 @@ function safeErrorText(error) {
|
|
|
126
135
|
}
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
function artifactFailureText(fileName, error) {
|
|
139
|
+
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
|
140
|
+
switch (error?.code) {
|
|
141
|
+
case 'artifact-permission-required':
|
|
142
|
+
return `结果文件「${name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。`;
|
|
143
|
+
case 'artifact-too-large':
|
|
144
|
+
return `结果文件「${name}」超过飞书 30 MB 上限,未发送。`;
|
|
145
|
+
case 'artifact-empty':
|
|
146
|
+
return `结果文件「${name}」为空,飞书不允许发送空文件。`;
|
|
147
|
+
case 'artifact-changed':
|
|
148
|
+
case 'artifact-invalid':
|
|
149
|
+
case 'artifact-unavailable':
|
|
150
|
+
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
|
151
|
+
case 'artifact-rate-limited':
|
|
152
|
+
return `结果文件「${name}」暂时被飞书限流,未能发送,请稍后重试。`;
|
|
153
|
+
case 'artifact-delivery-uncertain':
|
|
154
|
+
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
|
155
|
+
default:
|
|
156
|
+
return `结果文件「${name}」已生成,但暂时未能发送,请稍后重试。`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function answerTextForDelivery(answer, artifacts) {
|
|
161
|
+
if (typeof answer === 'string' && answer.trim()) return answer;
|
|
162
|
+
return artifacts.length > 0 ? '结果文件已生成。' : answer;
|
|
163
|
+
}
|
|
164
|
+
|
|
129
165
|
function nonEmptyString(value) {
|
|
130
166
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
131
167
|
}
|
|
@@ -540,7 +576,10 @@ export class FeishuHarnessBridge {
|
|
|
540
576
|
.then(() => this.#handle(event, key, { alreadyRecorded }));
|
|
541
577
|
const settled = finalize
|
|
542
578
|
? work
|
|
543
|
-
.then(() =>
|
|
579
|
+
.then(async (receipt) => {
|
|
580
|
+
await this.#finishReaction(messageId, processingReaction, 'DONE');
|
|
581
|
+
return receipt;
|
|
582
|
+
})
|
|
544
583
|
.catch((error) => this.#handleMessageFailure(
|
|
545
584
|
event,
|
|
546
585
|
messageId,
|
|
@@ -736,10 +775,11 @@ export class FeishuHarnessBridge {
|
|
|
736
775
|
|
|
737
776
|
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
|
738
777
|
try {
|
|
739
|
-
await this.#answerWithStream(event, key, message);
|
|
778
|
+
const receipt = await this.#answerWithStream(event, key, message);
|
|
740
779
|
this.#status.messagesReplied += 1;
|
|
741
780
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
742
781
|
this.#status.lastError = null;
|
|
782
|
+
return receipt;
|
|
743
783
|
} finally {
|
|
744
784
|
await this.#cancelPendingInteraction(key);
|
|
745
785
|
await this.#approvals.closeRoute(key);
|
|
@@ -1578,6 +1618,81 @@ export class FeishuHarnessBridge {
|
|
|
1578
1618
|
};
|
|
1579
1619
|
}
|
|
1580
1620
|
|
|
1621
|
+
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
|
|
1622
|
+
const providerMessageIds = [];
|
|
1623
|
+
for (const chunk of splitText(answer)) {
|
|
1624
|
+
this.#signal?.throwIfAborted();
|
|
1625
|
+
const messageId = await this.#send(chatId, chunk);
|
|
1626
|
+
if (messageId) providerMessageIds.push(messageId);
|
|
1627
|
+
}
|
|
1628
|
+
return createDeliveryReceipt({
|
|
1629
|
+
deliveryId,
|
|
1630
|
+
presentation,
|
|
1631
|
+
providerMessageIds,
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
|
|
1636
|
+
const receipts = baseReceipt ? [baseReceipt] : [];
|
|
1637
|
+
let failureNoticeVisible = false;
|
|
1638
|
+
for (const artifact of artifacts) {
|
|
1639
|
+
this.#signal?.throwIfAborted();
|
|
1640
|
+
try {
|
|
1641
|
+
if (typeof this.#channel?.sendFile !== 'function') {
|
|
1642
|
+
const unavailable = new Error('Feishu file delivery is unavailable');
|
|
1643
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
1644
|
+
throw unavailable;
|
|
1645
|
+
}
|
|
1646
|
+
const file = await materializeOutboundArtifact(artifact, {
|
|
1647
|
+
signal: this.#signal,
|
|
1648
|
+
});
|
|
1649
|
+
receipts.push(await this.#channel.sendFile(chatId, file, {
|
|
1650
|
+
replyTo,
|
|
1651
|
+
signal: this.#signal,
|
|
1652
|
+
}));
|
|
1653
|
+
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if (this.#signal?.aborted) throw error;
|
|
1656
|
+
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
|
1657
|
+
this.#logger.warn?.(
|
|
1658
|
+
`[dsh-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
|
|
1659
|
+
);
|
|
1660
|
+
let noticeMessageId = null;
|
|
1661
|
+
try {
|
|
1662
|
+
noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
|
|
1663
|
+
failureNoticeVisible = true;
|
|
1664
|
+
} catch {
|
|
1665
|
+
this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
|
|
1666
|
+
}
|
|
1667
|
+
receipts.push(createArtifactFailureReceipt({
|
|
1668
|
+
artifactId: artifact?.artifactId ?? 'unknown',
|
|
1669
|
+
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
|
1670
|
+
error,
|
|
1671
|
+
providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
|
|
1672
|
+
}));
|
|
1673
|
+
} finally {
|
|
1674
|
+
releaseOutboundArtifact(artifact);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (receipts.length === 0) {
|
|
1678
|
+
return {
|
|
1679
|
+
receipt: createDeliveryReceipt({
|
|
1680
|
+
deliveryId: replyTo,
|
|
1681
|
+
presentation: 'feishu-files',
|
|
1682
|
+
}),
|
|
1683
|
+
failureNoticeVisible,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const receipt = receipts.length === 1
|
|
1687
|
+
? receipts[0]
|
|
1688
|
+
: mergeDeliveryReceipts({
|
|
1689
|
+
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
|
1690
|
+
presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
|
1691
|
+
receipts,
|
|
1692
|
+
});
|
|
1693
|
+
return { receipt, failureNoticeVisible };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1581
1696
|
async #answerWithStream(event, key, message) {
|
|
1582
1697
|
const chatId = event.message.chat_id;
|
|
1583
1698
|
const messageId = event.message.message_id;
|
|
@@ -1586,7 +1701,7 @@ export class FeishuHarnessBridge {
|
|
|
1586
1701
|
? await promptContentForMessage(message, { signal: this.#signal })
|
|
1587
1702
|
: undefined;
|
|
1588
1703
|
if (!this.#channel?.stream) {
|
|
1589
|
-
const { answer } = await askInWorkspaceSession({
|
|
1704
|
+
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1590
1705
|
harness: this.#harness,
|
|
1591
1706
|
state: this.#state,
|
|
1592
1707
|
key,
|
|
@@ -1596,15 +1711,41 @@ export class FeishuHarnessBridge {
|
|
|
1596
1711
|
existsOptions: { signal: this.#signal },
|
|
1597
1712
|
askOptions: this.#interactionAskOptions(event, key),
|
|
1598
1713
|
});
|
|
1599
|
-
|
|
1714
|
+
let textReceipt;
|
|
1715
|
+
let textSendError = null;
|
|
1716
|
+
try {
|
|
1717
|
+
textReceipt = await this.#sendAnswerText(
|
|
1718
|
+
chatId,
|
|
1719
|
+
answerTextForDelivery(answer, artifacts),
|
|
1720
|
+
{
|
|
1721
|
+
deliveryId: messageId,
|
|
1722
|
+
presentation: 'feishu-text',
|
|
1723
|
+
},
|
|
1724
|
+
);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
textSendError = error;
|
|
1727
|
+
this.#logger.warn?.(
|
|
1728
|
+
'[dsh-feishu] final text delivery failed; continuing with result files:',
|
|
1729
|
+
error,
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
|
1733
|
+
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
1734
|
+
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
1735
|
+
);
|
|
1736
|
+
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
|
1737
|
+
throw textSendError;
|
|
1738
|
+
}
|
|
1600
1739
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
1601
|
-
return;
|
|
1740
|
+
return delivery.receipt;
|
|
1602
1741
|
}
|
|
1603
1742
|
|
|
1604
1743
|
let promptStarted = false;
|
|
1605
1744
|
let completedAnswer = '';
|
|
1745
|
+
let completedArtifacts = [];
|
|
1746
|
+
let stream;
|
|
1606
1747
|
try {
|
|
1607
|
-
await this.#channel.stream(chatId, {
|
|
1748
|
+
stream = await this.#channel.stream(chatId, {
|
|
1608
1749
|
markdown: async (controller) => {
|
|
1609
1750
|
promptStarted = true;
|
|
1610
1751
|
const askOptions = {
|
|
@@ -1614,7 +1755,7 @@ export class FeishuHarnessBridge {
|
|
|
1614
1755
|
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
|
1615
1756
|
},
|
|
1616
1757
|
};
|
|
1617
|
-
|
|
1758
|
+
const completed = await askInWorkspaceSession({
|
|
1618
1759
|
harness: this.#harness,
|
|
1619
1760
|
state: this.#state,
|
|
1620
1761
|
key,
|
|
@@ -1623,26 +1764,56 @@ export class FeishuHarnessBridge {
|
|
|
1623
1764
|
createOptions: { signal: this.#signal },
|
|
1624
1765
|
existsOptions: { signal: this.#signal },
|
|
1625
1766
|
askOptions,
|
|
1626
|
-
})
|
|
1627
|
-
|
|
1767
|
+
});
|
|
1768
|
+
completedAnswer = completed.answer;
|
|
1769
|
+
completedArtifacts = completed.artifacts ?? [];
|
|
1770
|
+
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
|
1628
1771
|
},
|
|
1629
1772
|
}, { replyTo: messageId });
|
|
1630
|
-
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
|
1631
1773
|
} catch (error) {
|
|
1632
1774
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
1633
|
-
if (completedAnswer) {
|
|
1775
|
+
if (completedAnswer || completedArtifacts.length > 0) {
|
|
1634
1776
|
this.#logger.warn?.(
|
|
1635
1777
|
'[dsh-feishu] native stream failed after generation; sending final text:',
|
|
1636
1778
|
error.message,
|
|
1637
1779
|
);
|
|
1638
|
-
|
|
1780
|
+
let textReceipt;
|
|
1781
|
+
let textSendError = null;
|
|
1782
|
+
try {
|
|
1783
|
+
textReceipt = await this.#sendAnswerText(
|
|
1784
|
+
chatId,
|
|
1785
|
+
answerTextForDelivery(completedAnswer, completedArtifacts),
|
|
1786
|
+
{
|
|
1787
|
+
deliveryId: messageId,
|
|
1788
|
+
presentation: 'feishu-text-fallback',
|
|
1789
|
+
},
|
|
1790
|
+
);
|
|
1791
|
+
} catch (fallbackError) {
|
|
1792
|
+
textSendError = fallbackError;
|
|
1793
|
+
this.#logger.warn?.(
|
|
1794
|
+
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
|
1795
|
+
fallbackError,
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
const delivery = await this.#deliverArtifacts(
|
|
1799
|
+
chatId,
|
|
1800
|
+
messageId,
|
|
1801
|
+
completedArtifacts,
|
|
1802
|
+
textReceipt,
|
|
1803
|
+
);
|
|
1804
|
+
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
1805
|
+
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
1806
|
+
);
|
|
1807
|
+
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
|
1808
|
+
throw textSendError;
|
|
1809
|
+
}
|
|
1639
1810
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
1640
|
-
return;
|
|
1811
|
+
return delivery.receipt;
|
|
1641
1812
|
}
|
|
1642
1813
|
if (promptStarted) throw error;
|
|
1643
1814
|
|
|
1644
1815
|
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
|
1645
|
-
const { answer } = await askInWorkspaceSession({
|
|
1816
|
+
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
1646
1817
|
harness: this.#harness,
|
|
1647
1818
|
state: this.#state,
|
|
1648
1819
|
key,
|
|
@@ -1652,9 +1823,46 @@ export class FeishuHarnessBridge {
|
|
|
1652
1823
|
existsOptions: { signal: this.#signal },
|
|
1653
1824
|
askOptions: this.#interactionAskOptions(event, key),
|
|
1654
1825
|
});
|
|
1655
|
-
|
|
1826
|
+
let textReceipt;
|
|
1827
|
+
let textSendError = null;
|
|
1828
|
+
try {
|
|
1829
|
+
textReceipt = await this.#sendAnswerText(
|
|
1830
|
+
chatId,
|
|
1831
|
+
answerTextForDelivery(answer, artifacts),
|
|
1832
|
+
{
|
|
1833
|
+
deliveryId: messageId,
|
|
1834
|
+
presentation: 'feishu-text-fallback',
|
|
1835
|
+
},
|
|
1836
|
+
);
|
|
1837
|
+
} catch (fallbackError) {
|
|
1838
|
+
textSendError = fallbackError;
|
|
1839
|
+
this.#logger.warn?.(
|
|
1840
|
+
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
|
1841
|
+
fallbackError,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
|
1845
|
+
const artifactDispatched = delivery.receipt.artifacts.some(
|
|
1846
|
+
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
1847
|
+
);
|
|
1848
|
+
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
|
1849
|
+
throw textSendError;
|
|
1850
|
+
}
|
|
1656
1851
|
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
|
1852
|
+
return delivery.receipt;
|
|
1657
1853
|
}
|
|
1854
|
+
const delivery = await this.#deliverArtifacts(
|
|
1855
|
+
chatId,
|
|
1856
|
+
messageId,
|
|
1857
|
+
completedArtifacts,
|
|
1858
|
+
createDeliveryReceipt({
|
|
1859
|
+
deliveryId: messageId,
|
|
1860
|
+
presentation: 'feishu-cardkit',
|
|
1861
|
+
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
|
|
1862
|
+
}),
|
|
1863
|
+
);
|
|
1864
|
+
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
|
1865
|
+
return delivery.receipt;
|
|
1658
1866
|
}
|
|
1659
1867
|
|
|
1660
1868
|
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
|
4
|
+
import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
|
|
5
|
+
|
|
1
6
|
const STREAM_ELEMENT_ID = 'stream_md';
|
|
2
7
|
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
|
3
8
|
const MAX_STREAM_CHARS = 28000;
|
|
9
|
+
const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
|
|
10
|
+
|
|
11
|
+
const FILE_DELIVERY_ERRORS = new Map([
|
|
12
|
+
[99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
|
|
13
|
+
[234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
|
|
14
|
+
[234010, ['artifact-empty', 'Feishu does not accept empty files.']],
|
|
15
|
+
[230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
|
|
16
|
+
[230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
|
|
17
|
+
[230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
|
|
18
|
+
[230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
|
|
19
|
+
]);
|
|
4
20
|
|
|
5
21
|
function assertApiSuccess(operation, response) {
|
|
6
22
|
if (response?.code && response.code !== 0) {
|
|
@@ -9,6 +25,103 @@ function assertApiSuccess(operation, response) {
|
|
|
9
25
|
return response;
|
|
10
26
|
}
|
|
11
27
|
|
|
28
|
+
function providerErrorCode(cause) {
|
|
29
|
+
const pending = [cause];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
let fallback;
|
|
32
|
+
while (pending.length > 0) {
|
|
33
|
+
const value = pending.shift();
|
|
34
|
+
if (!value || seen.has(value)) continue;
|
|
35
|
+
if (typeof value === 'object') seen.add(value);
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
pending.push(...value);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const code = Number(value?.code);
|
|
41
|
+
if (Number.isFinite(code) && code !== 0) {
|
|
42
|
+
if (FILE_DELIVERY_ERRORS.has(code)) return code;
|
|
43
|
+
fallback ??= code;
|
|
44
|
+
}
|
|
45
|
+
pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
|
|
46
|
+
}
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
|
|
51
|
+
const explicitCode = providerCode === undefined || providerCode === null
|
|
52
|
+
? undefined
|
|
53
|
+
: Number(providerCode);
|
|
54
|
+
const code = Number.isFinite(explicitCode) && explicitCode !== 0
|
|
55
|
+
? explicitCode
|
|
56
|
+
: providerErrorCode(cause);
|
|
57
|
+
const fallback = Number.isFinite(code)
|
|
58
|
+
? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
|
|
59
|
+
: uncertain
|
|
60
|
+
? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
|
|
61
|
+
: ['artifact-provider-failed', `Feishu file ${stage} failed.`];
|
|
62
|
+
const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
|
|
63
|
+
const error = new Error(message, { cause });
|
|
64
|
+
error.code = errorCode;
|
|
65
|
+
if (Number.isFinite(code)) error.providerCode = code;
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function boundedFileTimeout(value, name) {
|
|
70
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
|
|
71
|
+
throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function abortReason(signal) {
|
|
77
|
+
return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function operationTimeout(stage) {
|
|
81
|
+
const error = new Error(`Feishu file ${stage} timed out.`);
|
|
82
|
+
error.code = 'provider-timeout';
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
|
87
|
+
signal?.throwIfAborted();
|
|
88
|
+
const deadline = new AbortController();
|
|
89
|
+
const operationSignal = signal
|
|
90
|
+
? AbortSignal.any([signal, deadline.signal])
|
|
91
|
+
: deadline.signal;
|
|
92
|
+
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (callback, value) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
operationSignal.removeEventListener('abort', onAbort);
|
|
100
|
+
callback(value);
|
|
101
|
+
};
|
|
102
|
+
const onAbort = () => finish(
|
|
103
|
+
reject,
|
|
104
|
+
signal?.aborted ? abortReason(signal) : operationTimeout(stage),
|
|
105
|
+
);
|
|
106
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
107
|
+
operationSignal.addEventListener('abort', onAbort, { once: true });
|
|
108
|
+
|
|
109
|
+
Promise.resolve().then(() => operation(operationSignal)).then(
|
|
110
|
+
(value) => finish(resolve, value),
|
|
111
|
+
(error) => finish(reject, error),
|
|
112
|
+
);
|
|
113
|
+
if (operationSignal.aborted) onAbort();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deliveryUuid(file, chatId) {
|
|
118
|
+
const digest = createHash('sha256')
|
|
119
|
+
.update(`${file.deliveryKey}\u0000${chatId}`)
|
|
120
|
+
.digest('hex')
|
|
121
|
+
.slice(0, 40);
|
|
122
|
+
return `dshim_${digest}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
12
125
|
function summaryOf(text) {
|
|
13
126
|
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
14
127
|
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
|
|
@@ -39,10 +152,19 @@ function streamingCard(initialText) {
|
|
|
39
152
|
export class VerifiedFeishuChannel {
|
|
40
153
|
#client;
|
|
41
154
|
#initialText;
|
|
155
|
+
#fileUploadTimeoutMs;
|
|
156
|
+
#fileMessageTimeoutMs;
|
|
42
157
|
|
|
43
|
-
constructor({
|
|
158
|
+
constructor({
|
|
159
|
+
client,
|
|
160
|
+
initialText = DEFAULT_INITIAL_TEXT,
|
|
161
|
+
fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
|
162
|
+
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
|
163
|
+
}) {
|
|
44
164
|
this.#client = client;
|
|
45
165
|
this.#initialText = initialText;
|
|
166
|
+
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
|
167
|
+
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
|
|
46
168
|
}
|
|
47
169
|
|
|
48
170
|
async stream(chatId, input, options = {}) {
|
|
@@ -107,6 +229,110 @@ export class VerifiedFeishuChannel {
|
|
|
107
229
|
}
|
|
108
230
|
}
|
|
109
231
|
|
|
232
|
+
async sendFile(chatId, file, { replyTo, signal } = {}) {
|
|
233
|
+
signal?.throwIfAborted();
|
|
234
|
+
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
|
|
235
|
+
if (!file || typeof file !== 'object'
|
|
236
|
+
|| typeof file.artifactId !== 'string' || !file.artifactId
|
|
237
|
+
|| typeof file.deliveryKey !== 'string' || !file.deliveryKey
|
|
238
|
+
|| typeof file.fileName !== 'string' || !file.fileName
|
|
239
|
+
|| !Buffer.isBuffer(file.bytes)) {
|
|
240
|
+
throw new TypeError('A materialized result file is required');
|
|
241
|
+
}
|
|
242
|
+
let uploaded;
|
|
243
|
+
try {
|
|
244
|
+
uploaded = await waitForFileOperation((operationSignal) => {
|
|
245
|
+
operationSignal.throwIfAborted();
|
|
246
|
+
const pending = this.#client.im.v1.file.create({
|
|
247
|
+
data: {
|
|
248
|
+
file_type: 'stream',
|
|
249
|
+
file_name: file.fileName,
|
|
250
|
+
file: file.bytes,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
254
|
+
return pending;
|
|
255
|
+
}, {
|
|
256
|
+
signal,
|
|
257
|
+
timeoutMs: this.#fileUploadTimeoutMs,
|
|
258
|
+
stage: 'upload',
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
262
|
+
throw fileDeliveryError('upload', error);
|
|
263
|
+
}
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
const fileKey = uploaded?.file_key;
|
|
266
|
+
if (typeof fileKey !== 'string' || !fileKey) {
|
|
267
|
+
throw fileDeliveryError('upload', undefined, uploaded?.code);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const uuid = deliveryUuid(file, chatId);
|
|
271
|
+
const content = JSON.stringify({ file_key: fileKey });
|
|
272
|
+
const request = replyTo
|
|
273
|
+
? {
|
|
274
|
+
path: { message_id: replyTo },
|
|
275
|
+
data: { msg_type: 'file', content, uuid },
|
|
276
|
+
}
|
|
277
|
+
: {
|
|
278
|
+
params: { receive_id_type: 'chat_id' },
|
|
279
|
+
data: { receive_id: chatId, msg_type: 'file', content, uuid },
|
|
280
|
+
};
|
|
281
|
+
const send = () => {
|
|
282
|
+
const pending = replyTo
|
|
283
|
+
? this.#client.im.v1.message.reply(request)
|
|
284
|
+
: this.#client.im.v1.message.create(request);
|
|
285
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
286
|
+
return pending;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
let response;
|
|
290
|
+
try {
|
|
291
|
+
response = await waitForFileOperation(async (operationSignal) => {
|
|
292
|
+
operationSignal.throwIfAborted();
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await send();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (providerErrorCode(error) !== 230049) throw error;
|
|
298
|
+
result = { code: 230049 };
|
|
299
|
+
}
|
|
300
|
+
operationSignal.throwIfAborted();
|
|
301
|
+
|
|
302
|
+
// Feishu documents 230049 as an uncertain asynchronous send result.
|
|
303
|
+
// Reuse the same file_key and UUID once so the provider can deduplicate.
|
|
304
|
+
if (Number(result?.code) === 230049) {
|
|
305
|
+
result = await send();
|
|
306
|
+
operationSignal.throwIfAborted();
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}, {
|
|
310
|
+
signal,
|
|
311
|
+
timeoutMs: this.#fileMessageTimeoutMs,
|
|
312
|
+
stage: 'message send',
|
|
313
|
+
});
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
316
|
+
throw fileDeliveryError('message send', error, undefined, { uncertain: true });
|
|
317
|
+
}
|
|
318
|
+
if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
|
|
319
|
+
throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
|
|
320
|
+
}
|
|
321
|
+
const messageId = response?.data?.message_id;
|
|
322
|
+
if (typeof messageId !== 'string' || !messageId) {
|
|
323
|
+
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
|
324
|
+
}
|
|
325
|
+
return createDeliveryReceipt({
|
|
326
|
+
deliveryId: file.deliveryKey,
|
|
327
|
+
presentation: 'feishu-file',
|
|
328
|
+
providerMessageIds: [messageId],
|
|
329
|
+
artifacts: [{
|
|
330
|
+
artifactId: file.artifactId,
|
|
331
|
+
outcome: 'sent',
|
|
332
|
+
}],
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
110
336
|
async #sendCard(chatId, cardId, replyTo) {
|
|
111
337
|
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
|
112
338
|
const response = replyTo
|