@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
|
@@ -120,6 +120,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|
|
120
120
|
let inFlight = null;
|
|
121
121
|
let broken = false;
|
|
122
122
|
let closed = false;
|
|
123
|
+
const providerMessageIds = [ts];
|
|
123
124
|
|
|
124
125
|
const appendLatest = async (text) => {
|
|
125
126
|
const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
|
|
@@ -150,6 +151,10 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|
|
150
151
|
};
|
|
151
152
|
|
|
152
153
|
return {
|
|
154
|
+
messageId: ts,
|
|
155
|
+
get providerMessageIds() {
|
|
156
|
+
return [...providerMessageIds];
|
|
157
|
+
},
|
|
153
158
|
update(text) {
|
|
154
159
|
if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return;
|
|
155
160
|
pending = text;
|
|
@@ -173,12 +178,13 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|
|
173
178
|
await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
|
|
174
179
|
}
|
|
175
180
|
for (const chunk of chunks.slice(1)) {
|
|
176
|
-
await api.postMessage({
|
|
181
|
+
const result = await api.postMessage({
|
|
177
182
|
channelId: target.channelId,
|
|
178
183
|
threadTs: target.threadTs,
|
|
179
184
|
text: chunk,
|
|
180
185
|
signal,
|
|
181
186
|
});
|
|
187
|
+
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
|
182
188
|
}
|
|
183
189
|
},
|
|
184
190
|
cancel() {
|
|
@@ -191,7 +197,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
|
|
|
191
197
|
};
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
class SlackBotClient {
|
|
200
|
+
export class SlackBotClient {
|
|
195
201
|
#api;
|
|
196
202
|
#signal;
|
|
197
203
|
#logger;
|
|
@@ -204,16 +210,17 @@ class SlackBotClient {
|
|
|
204
210
|
|
|
205
211
|
async sendText(target, text) {
|
|
206
212
|
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
|
|
207
|
-
|
|
213
|
+
const providerMessageIds = [];
|
|
208
214
|
for (const chunk of chunks) {
|
|
209
|
-
result = await this.#api.postMessage({
|
|
215
|
+
const result = await this.#api.postMessage({
|
|
210
216
|
channelId: target.channelId,
|
|
211
217
|
threadTs: target.threadTs,
|
|
212
218
|
text: chunk,
|
|
213
219
|
signal: this.#signal,
|
|
214
220
|
});
|
|
221
|
+
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
|
215
222
|
}
|
|
216
|
-
return
|
|
223
|
+
return { providerMessageIds };
|
|
217
224
|
}
|
|
218
225
|
|
|
219
226
|
openStream(target) {
|
|
@@ -224,6 +231,15 @@ class SlackBotClient {
|
|
|
224
231
|
logger: this.#logger,
|
|
225
232
|
});
|
|
226
233
|
}
|
|
234
|
+
|
|
235
|
+
sendFile(target, file) {
|
|
236
|
+
return this.#api.uploadFile({
|
|
237
|
+
channelId: target.channelId,
|
|
238
|
+
threadTs: target.threadTs,
|
|
239
|
+
file,
|
|
240
|
+
signal: this.#signal,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
227
243
|
}
|
|
228
244
|
|
|
229
245
|
export function createSlackRuntimeStatus() {
|
|
@@ -2,6 +2,7 @@ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
|
4
4
|
const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
|
|
5
|
+
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
5
6
|
|
|
6
7
|
function cleanString(value) {
|
|
7
8
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -12,6 +13,58 @@ function requestSignal(signal, timeoutMs) {
|
|
|
12
13
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
function abortReason(signal) {
|
|
17
|
+
return signal?.reason instanceof Error
|
|
18
|
+
? signal.reason
|
|
19
|
+
: new DOMException('The operation was aborted', 'AbortError');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function positiveTimeout(value, name) {
|
|
23
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function preserveProviderMetadata(target, source) {
|
|
28
|
+
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
|
|
29
|
+
if (source?.retry_after !== undefined) {
|
|
30
|
+
target.retry_after = source.retry_after;
|
|
31
|
+
target.retryAfter = source.retry_after;
|
|
32
|
+
}
|
|
33
|
+
if (Number.isInteger(source?.status)) target.status = source.status;
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function telegramArtifactProviderError(cause) {
|
|
38
|
+
const providerCode = Number(cause?.providerCode);
|
|
39
|
+
const status = Number(cause?.status);
|
|
40
|
+
const message = cleanString(cause?.message) ?? '';
|
|
41
|
+
let code = 'artifact-provider-rejected';
|
|
42
|
+
let summary = 'Telegram rejected the document.';
|
|
43
|
+
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
|
|
44
|
+
code = 'artifact-permission-required';
|
|
45
|
+
summary = 'Telegram denied permission to send the document.';
|
|
46
|
+
} else if (providerCode === 413 || status === 413
|
|
47
|
+
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
|
|
48
|
+
code = 'artifact-too-large';
|
|
49
|
+
summary = 'The document exceeds Telegram\'s size limit.';
|
|
50
|
+
} else if (providerCode === 429 || status === 429) {
|
|
51
|
+
code = 'artifact-rate-limited';
|
|
52
|
+
summary = 'Telegram rate-limited document delivery.';
|
|
53
|
+
} else if (providerCode >= 500 || status >= 500) {
|
|
54
|
+
code = 'artifact-delivery-uncertain';
|
|
55
|
+
summary = 'Telegram document delivery result is uncertain.';
|
|
56
|
+
}
|
|
57
|
+
const error = new Error(summary, { cause });
|
|
58
|
+
error.code = code;
|
|
59
|
+
return preserveProviderMetadata(error, cause);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function uncertainTelegramDelivery(cause) {
|
|
63
|
+
const error = new Error('Telegram document delivery result is uncertain', { cause });
|
|
64
|
+
error.code = 'artifact-delivery-uncertain';
|
|
65
|
+
return preserveProviderMetadata(error, cause);
|
|
66
|
+
}
|
|
67
|
+
|
|
15
68
|
export function validTelegramToken(value) {
|
|
16
69
|
return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
|
17
70
|
}
|
|
@@ -32,13 +85,20 @@ export class TelegramApi {
|
|
|
32
85
|
#token;
|
|
33
86
|
#fetch;
|
|
34
87
|
#baseUrl;
|
|
88
|
+
#fileUploadTimeoutMs;
|
|
35
89
|
|
|
36
|
-
constructor({
|
|
90
|
+
constructor({
|
|
91
|
+
token,
|
|
92
|
+
fetchImpl = fetch,
|
|
93
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
94
|
+
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
|
95
|
+
}) {
|
|
37
96
|
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
|
|
38
97
|
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
|
|
39
98
|
this.#token = token.trim();
|
|
40
99
|
this.#fetch = fetchImpl;
|
|
41
100
|
this.#baseUrl = new URL(baseUrl);
|
|
101
|
+
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
|
42
102
|
}
|
|
43
103
|
|
|
44
104
|
async getMe(options = {}) {
|
|
@@ -108,6 +168,43 @@ export class TelegramApi {
|
|
|
108
168
|
}, { signal });
|
|
109
169
|
}
|
|
110
170
|
|
|
171
|
+
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
|
172
|
+
if (!file || typeof file !== 'object'
|
|
173
|
+
|| typeof file.fileName !== 'string' || !file.fileName
|
|
174
|
+
|| !Buffer.isBuffer(file.bytes)) {
|
|
175
|
+
throw new TypeError('A Telegram document is required');
|
|
176
|
+
}
|
|
177
|
+
const payload = new FormData();
|
|
178
|
+
payload.append('chat_id', String(chatId));
|
|
179
|
+
payload.append(
|
|
180
|
+
'document',
|
|
181
|
+
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
|
182
|
+
file.fileName,
|
|
183
|
+
);
|
|
184
|
+
if (replyToMessageId) {
|
|
185
|
+
payload.append('reply_parameters', JSON.stringify({
|
|
186
|
+
message_id: replyToMessageId,
|
|
187
|
+
allow_sending_without_reply: true,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
if (messageThreadId) payload.append('message_thread_id', String(messageThreadId));
|
|
191
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
192
|
+
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
|
193
|
+
try {
|
|
194
|
+
return await this.#call('sendDocument', payload, {
|
|
195
|
+
signal: uploadSignal,
|
|
196
|
+
timeoutMs: this.#fileUploadTimeoutMs,
|
|
197
|
+
multipart: true,
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
201
|
+
if (error?.code?.startsWith?.('telegram-')) {
|
|
202
|
+
throw telegramArtifactProviderError(error);
|
|
203
|
+
}
|
|
204
|
+
throw uncertainTelegramDelivery(error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
111
208
|
async editMessageText({ chatId, messageId, text, signal }) {
|
|
112
209
|
return this.#call('editMessageText', {
|
|
113
210
|
chat_id: chatId,
|
|
@@ -144,15 +241,15 @@ export class TelegramApi {
|
|
|
144
241
|
return this.#call('setChatMenuButton', { menu_button: menuButton }, { signal });
|
|
145
242
|
}
|
|
146
243
|
|
|
147
|
-
async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
|
|
244
|
+
async #call(method, payload, { signal, timeoutMs = 15_000, multipart = false } = {}) {
|
|
148
245
|
const url = new URL(this.#baseUrl);
|
|
149
246
|
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
|
|
150
247
|
let response;
|
|
151
248
|
try {
|
|
152
249
|
response = await this.#fetch(url, {
|
|
153
250
|
method: 'POST',
|
|
154
|
-
headers: { 'content-type': 'application/json' },
|
|
155
|
-
body: JSON.stringify(payload),
|
|
251
|
+
...(multipart ? {} : { headers: { 'content-type': 'application/json' } }),
|
|
252
|
+
body: multipart ? payload : JSON.stringify(payload),
|
|
156
253
|
signal: requestSignal(signal, timeoutMs),
|
|
157
254
|
redirect: 'error',
|
|
158
255
|
});
|
|
@@ -164,12 +261,21 @@ export class TelegramApi {
|
|
|
164
261
|
try {
|
|
165
262
|
body = await response.json();
|
|
166
263
|
} catch {
|
|
167
|
-
|
|
264
|
+
const error = new Error(`Telegram ${method} returned invalid JSON`);
|
|
265
|
+
error.status = response?.status;
|
|
266
|
+
throw error;
|
|
168
267
|
}
|
|
169
268
|
if (!response.ok || body?.ok !== true) {
|
|
170
269
|
const description = cleanString(body?.description);
|
|
171
270
|
const error = new Error(description ?? `Telegram ${method} failed`);
|
|
172
271
|
error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
|
|
272
|
+
error.status = response.status;
|
|
273
|
+
if (Number.isInteger(body?.error_code)) error.providerCode = body.error_code;
|
|
274
|
+
const retryAfter = Number(body?.parameters?.retry_after);
|
|
275
|
+
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
276
|
+
error.retry_after = retryAfter;
|
|
277
|
+
error.retryAfter = retryAfter;
|
|
278
|
+
}
|
|
173
279
|
throw error;
|
|
174
280
|
}
|
|
175
281
|
return body.result;
|
|
@@ -146,7 +146,7 @@ export function telegramInboundAllowed(message, {
|
|
|
146
146
|
&& allowedPrivateUserIds.has(String(message.senderId));
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
class TelegramBotClient {
|
|
149
|
+
export class TelegramBotClient {
|
|
150
150
|
#api;
|
|
151
151
|
#signal;
|
|
152
152
|
|
|
@@ -157,17 +157,20 @@ class TelegramBotClient {
|
|
|
157
157
|
|
|
158
158
|
async sendText(target, text) {
|
|
159
159
|
const chunks = splitMessageText(text, 4_000);
|
|
160
|
-
|
|
160
|
+
const providerMessageIds = [];
|
|
161
161
|
for (const [index, chunk] of chunks.entries()) {
|
|
162
|
-
result = await this.#api.sendMessage({
|
|
162
|
+
const result = await this.#api.sendMessage({
|
|
163
163
|
chatId: target.chatId,
|
|
164
164
|
text: chunk,
|
|
165
165
|
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
|
166
166
|
messageThreadId: target.messageThreadId,
|
|
167
167
|
signal: this.#signal,
|
|
168
168
|
});
|
|
169
|
+
if (Number.isSafeInteger(result?.message_id)) {
|
|
170
|
+
providerMessageIds.push(String(result.message_id));
|
|
171
|
+
}
|
|
169
172
|
}
|
|
170
|
-
return
|
|
173
|
+
return { providerMessageIds };
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
sendTyping(target) {
|
|
@@ -178,6 +181,16 @@ class TelegramBotClient {
|
|
|
178
181
|
});
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
sendFile(target, file) {
|
|
185
|
+
return this.#api.sendDocument({
|
|
186
|
+
chatId: target.chatId,
|
|
187
|
+
file,
|
|
188
|
+
replyToMessageId: target.replyToMessageId,
|
|
189
|
+
messageThreadId: target.messageThreadId,
|
|
190
|
+
signal: this.#signal,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
async openStream(target) {
|
|
182
195
|
const stream = createEditableMessageStream({
|
|
183
196
|
limit: 4_000,
|
|
@@ -203,6 +216,7 @@ class TelegramBotClient {
|
|
|
203
216
|
messageThreadId: target.messageThreadId,
|
|
204
217
|
signal: this.#signal,
|
|
205
218
|
}),
|
|
219
|
+
messageIdForResult: (message) => message?.message_id,
|
|
206
220
|
});
|
|
207
221
|
return stream.start();
|
|
208
222
|
}
|
|
@@ -27,6 +27,18 @@ import {
|
|
|
27
27
|
promptContentForMessage,
|
|
28
28
|
} from '../shared/image-prompt.mjs';
|
|
29
29
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
|
30
|
+
import {
|
|
31
|
+
materializeOutboundArtifact,
|
|
32
|
+
releaseOutboundArtifact,
|
|
33
|
+
trackOutboundArtifactProviderPromise,
|
|
34
|
+
} from '../shared/semantic/artifact.mjs';
|
|
35
|
+
import {
|
|
36
|
+
createArtifactFailureReceipt,
|
|
37
|
+
createDeliveryReceipt,
|
|
38
|
+
mergeDeliveryReceipts,
|
|
39
|
+
} from '../shared/semantic/delivery.mjs';
|
|
40
|
+
|
|
41
|
+
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
|
30
42
|
|
|
31
43
|
const HELP_TEXT = [
|
|
32
44
|
'企业微信机器人已连接 DeepSeek Harness。',
|
|
@@ -212,6 +224,88 @@ function progressText(update) {
|
|
|
212
224
|
return update?.text;
|
|
213
225
|
}
|
|
214
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}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。`;
|
|
234
|
+
case 'artifact-too-large':
|
|
235
|
+
return `结果文件「${name}」超过当前企业微信机器人可发送的文件大小,未发送。`;
|
|
236
|
+
case 'artifact-empty':
|
|
237
|
+
return `结果文件「${name}」为空,企业微信不允许发送空文件。`;
|
|
238
|
+
case 'artifact-changed':
|
|
239
|
+
case 'artifact-invalid':
|
|
240
|
+
case 'artifact-unavailable':
|
|
241
|
+
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
|
242
|
+
case 'artifact-rate-limited':
|
|
243
|
+
return `结果文件「${name}」暂时被企业微信限流,未能发送,请稍后重试。`;
|
|
244
|
+
case 'artifact-provider-rejected':
|
|
245
|
+
return `结果文件「${name}」已生成,但企业微信拒绝了该文件或文件消息。`;
|
|
246
|
+
default:
|
|
247
|
+
return `结果文件「${name}」已生成,但暂时未能通过企业微信发送,请稍后重试。`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function abortReason(signal) {
|
|
252
|
+
return signal?.reason instanceof Error
|
|
253
|
+
? signal.reason
|
|
254
|
+
: new DOMException('The operation was aborted', 'AbortError');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function waitWithSignal(promise, signal) {
|
|
258
|
+
if (!signal) return promise;
|
|
259
|
+
signal.throwIfAborted();
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
let settled = false;
|
|
262
|
+
const finish = (callback, value) => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
signal.removeEventListener('abort', onAbort);
|
|
266
|
+
callback(value);
|
|
267
|
+
};
|
|
268
|
+
const onAbort = () => finish(reject, abortReason(signal));
|
|
269
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
270
|
+
Promise.resolve(promise).then(
|
|
271
|
+
(value) => finish(resolve, value),
|
|
272
|
+
(error) => finish(reject, error),
|
|
273
|
+
);
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function wecomArtifactError(error, { dispatched = false } = {}) {
|
|
279
|
+
if (error?.code?.startsWith?.('artifact-')) return error;
|
|
280
|
+
const status = Number(error?.httpStatus ?? error?.status ?? error?.response?.status);
|
|
281
|
+
const providerCode = Number(error?.providerCode ?? error?.errcode ?? error?.body?.errcode);
|
|
282
|
+
const wrapped = new Error('Enterprise WeChat file delivery failed', { cause: error });
|
|
283
|
+
if (status === 401 || status === 403 || providerCode === 48002) {
|
|
284
|
+
wrapped.code = 'artifact-permission-required';
|
|
285
|
+
} else if (status === 413) {
|
|
286
|
+
wrapped.code = 'artifact-too-large';
|
|
287
|
+
} else if (status === 429 || providerCode === 45009) {
|
|
288
|
+
wrapped.code = 'artifact-rate-limited';
|
|
289
|
+
} else if (Number.isFinite(providerCode) && providerCode !== 0) {
|
|
290
|
+
wrapped.code = 'artifact-provider-rejected';
|
|
291
|
+
} else {
|
|
292
|
+
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
|
293
|
+
}
|
|
294
|
+
if (Number.isFinite(status)) wrapped.status = status;
|
|
295
|
+
if (Number.isFinite(providerCode)) wrapped.providerCode = providerCode;
|
|
296
|
+
return wrapped;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function answerTextForDelivery(answer, artifacts) {
|
|
300
|
+
if (typeof answer === 'string' && answer.trim()) return answer;
|
|
301
|
+
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function providerMessageId(result) {
|
|
305
|
+
return nonEmptyString(result?.body?.msgid)
|
|
306
|
+
?? nonEmptyString(result?.body?.message_id);
|
|
307
|
+
}
|
|
308
|
+
|
|
215
309
|
function canClaimInteractionReply(frame, pending) {
|
|
216
310
|
return pending.questions[pending.index]
|
|
217
311
|
&& nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
|
|
@@ -223,6 +317,8 @@ export function createWecomBridgeStatus() {
|
|
|
223
317
|
messagesReceived: 0,
|
|
224
318
|
messagesReplied: 0,
|
|
225
319
|
messagesRejected: 0,
|
|
320
|
+
artifactsSent: 0,
|
|
321
|
+
artifactSendErrors: 0,
|
|
226
322
|
lastMessageAt: null,
|
|
227
323
|
lastReplyAt: null,
|
|
228
324
|
lastRejectedAt: null,
|
|
@@ -239,6 +335,7 @@ export class WecomHarnessBridge {
|
|
|
239
335
|
#replyTimeoutMs;
|
|
240
336
|
#generateReqId;
|
|
241
337
|
#signal;
|
|
338
|
+
#fileUploadTimeoutMs;
|
|
242
339
|
#queues = new Map();
|
|
243
340
|
#pendingInteractions = new Map();
|
|
244
341
|
#interactionKeys = new Map();
|
|
@@ -256,12 +353,16 @@ export class WecomHarnessBridge {
|
|
|
256
353
|
logger = console,
|
|
257
354
|
replyTimeoutMs = 600_000,
|
|
258
355
|
generateStreamId = generateReqId,
|
|
356
|
+
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
|
259
357
|
signal,
|
|
260
358
|
}) {
|
|
261
359
|
if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
|
|
262
360
|
throw new TypeError('Enterprise WeChat client is required');
|
|
263
361
|
}
|
|
264
362
|
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
|
363
|
+
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
|
|
364
|
+
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
|
|
365
|
+
}
|
|
265
366
|
this.#client = client;
|
|
266
367
|
this.#harness = harness;
|
|
267
368
|
this.#state = state;
|
|
@@ -269,6 +370,7 @@ export class WecomHarnessBridge {
|
|
|
269
370
|
this.#logger = logger;
|
|
270
371
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
271
372
|
this.#generateReqId = generateStreamId;
|
|
373
|
+
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
|
|
272
374
|
this.#signal = signal;
|
|
273
375
|
this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
|
|
274
376
|
}
|
|
@@ -458,10 +560,17 @@ export class WecomHarnessBridge {
|
|
|
458
560
|
}
|
|
459
561
|
|
|
460
562
|
async #sendActive(chatId, text) {
|
|
563
|
+
const providerMessageIds = [];
|
|
461
564
|
for (const chunk of splitUtf8(text)) {
|
|
462
565
|
this.#signal?.throwIfAborted();
|
|
463
|
-
await this.#client.sendMessage(
|
|
566
|
+
const result = await this.#client.sendMessage(
|
|
567
|
+
chatId,
|
|
568
|
+
{ msgtype: 'markdown', markdown: { content: chunk } },
|
|
569
|
+
);
|
|
570
|
+
const messageId = providerMessageId(result);
|
|
571
|
+
if (messageId) providerMessageIds.push(messageId);
|
|
464
572
|
}
|
|
573
|
+
return providerMessageIds;
|
|
465
574
|
}
|
|
466
575
|
|
|
467
576
|
async #sendImmediate(frame, chatId, text) {
|
|
@@ -478,6 +587,105 @@ export class WecomHarnessBridge {
|
|
|
478
587
|
}
|
|
479
588
|
}
|
|
480
589
|
|
|
590
|
+
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt = null) {
|
|
591
|
+
if (artifacts.length === 0) {
|
|
592
|
+
return { receipt: baseReceipt, failureNoticeVisible: false };
|
|
593
|
+
}
|
|
594
|
+
const receipts = baseReceipt ? [baseReceipt] : [];
|
|
595
|
+
let failureNoticeVisible = false;
|
|
596
|
+
for (const artifact of artifacts) {
|
|
597
|
+
this.#signal?.throwIfAborted();
|
|
598
|
+
try {
|
|
599
|
+
if (typeof this.#client.uploadMedia !== 'function'
|
|
600
|
+
|| typeof this.#client.sendMediaMessage !== 'function') {
|
|
601
|
+
const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
|
|
602
|
+
unavailable.code = 'artifact-provider-unavailable';
|
|
603
|
+
throw unavailable;
|
|
604
|
+
}
|
|
605
|
+
const file = await materializeOutboundArtifact(artifact, {
|
|
606
|
+
signal: this.#signal,
|
|
607
|
+
});
|
|
608
|
+
this.#signal?.throwIfAborted();
|
|
609
|
+
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
|
610
|
+
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
|
611
|
+
let uploaded;
|
|
612
|
+
try {
|
|
613
|
+
const pending = this.#client.uploadMedia(file.bytes, {
|
|
614
|
+
type: 'file',
|
|
615
|
+
filename: file.fileName,
|
|
616
|
+
});
|
|
617
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
618
|
+
uploaded = await waitWithSignal(pending, waitSignal);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
|
621
|
+
throw wecomArtifactError(error);
|
|
622
|
+
}
|
|
623
|
+
this.#signal?.throwIfAborted();
|
|
624
|
+
const mediaId = nonEmptyString(uploaded?.media_id);
|
|
625
|
+
if (!mediaId) {
|
|
626
|
+
const rejected = new Error('Enterprise WeChat upload returned no media id');
|
|
627
|
+
rejected.code = 'artifact-provider-rejected';
|
|
628
|
+
throw rejected;
|
|
629
|
+
}
|
|
630
|
+
let sent;
|
|
631
|
+
try {
|
|
632
|
+
const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
|
|
633
|
+
trackOutboundArtifactProviderPromise(file, pending);
|
|
634
|
+
sent = await waitWithSignal(pending, waitSignal);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
|
637
|
+
throw wecomArtifactError(error, { dispatched: true });
|
|
638
|
+
}
|
|
639
|
+
this.#signal?.throwIfAborted();
|
|
640
|
+
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
|
641
|
+
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
|
642
|
+
throw wecomArtifactError({ providerCode });
|
|
643
|
+
}
|
|
644
|
+
const messageId = providerMessageId(sent);
|
|
645
|
+
receipts.push(createDeliveryReceipt({
|
|
646
|
+
deliveryId: file.deliveryKey,
|
|
647
|
+
presentation: 'wecom-file',
|
|
648
|
+
providerMessageIds: messageId ? [messageId] : [],
|
|
649
|
+
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
|
650
|
+
}));
|
|
651
|
+
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (this.#signal?.aborted) throw error;
|
|
654
|
+
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
|
655
|
+
this.#logger.warn?.(
|
|
656
|
+
`[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
|
|
657
|
+
);
|
|
658
|
+
let providerMessageIds = [];
|
|
659
|
+
try {
|
|
660
|
+
providerMessageIds = await this.#sendActive(
|
|
661
|
+
chatId,
|
|
662
|
+
artifactFailureText(artifact?.fileName, error),
|
|
663
|
+
);
|
|
664
|
+
failureNoticeVisible = true;
|
|
665
|
+
} catch (noticeError) {
|
|
666
|
+
if (this.#signal?.aborted) throw noticeError;
|
|
667
|
+
this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
|
|
668
|
+
}
|
|
669
|
+
receipts.push(createArtifactFailureReceipt({
|
|
670
|
+
artifactId: artifact?.artifactId ?? 'unknown',
|
|
671
|
+
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
|
672
|
+
error,
|
|
673
|
+
providerMessageIds,
|
|
674
|
+
}));
|
|
675
|
+
} finally {
|
|
676
|
+
releaseOutboundArtifact(artifact);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
receipt: mergeDeliveryReceipts({
|
|
681
|
+
deliveryId: replyTo,
|
|
682
|
+
presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
|
683
|
+
receipts,
|
|
684
|
+
}),
|
|
685
|
+
failureNoticeVisible,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
481
689
|
async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
|
|
482
690
|
if (this.#signal?.aborted) return;
|
|
483
691
|
const body = bodyOf(frame);
|
|
@@ -556,7 +764,7 @@ export class WecomHarnessBridge {
|
|
|
556
764
|
const content = hasImages
|
|
557
765
|
? await promptContentForMessage(message, { signal: this.#signal })
|
|
558
766
|
: undefined;
|
|
559
|
-
const { answer } = await askInWorkspaceSession({
|
|
767
|
+
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
560
768
|
harness: this.#harness,
|
|
561
769
|
state: this.#state,
|
|
562
770
|
key,
|
|
@@ -584,24 +792,64 @@ export class WecomHarnessBridge {
|
|
|
584
792
|
},
|
|
585
793
|
});
|
|
586
794
|
|
|
587
|
-
|
|
795
|
+
this.#signal?.throwIfAborted();
|
|
796
|
+
const displayAnswer = answerTextForDelivery(answer, artifacts);
|
|
797
|
+
const chunks = splitUtf8(displayAnswer);
|
|
588
798
|
let finalSent = false;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
799
|
+
let textReceipt = null;
|
|
800
|
+
let textSendError = null;
|
|
801
|
+
try {
|
|
802
|
+
if (streamStarted && chunks.length > 0) {
|
|
803
|
+
try {
|
|
804
|
+
const providerMessageIds = [];
|
|
805
|
+
const streamed = await this.#client.replyStream(frame, streamId, chunks[0], true);
|
|
806
|
+
const streamedMessageId = providerMessageId(streamed);
|
|
807
|
+
if (streamedMessageId) providerMessageIds.push(streamedMessageId);
|
|
808
|
+
for (const chunk of chunks.slice(1)) {
|
|
809
|
+
const sent = await this.#client.sendMessage(
|
|
810
|
+
chatId,
|
|
811
|
+
{ msgtype: 'markdown', markdown: { content: chunk } },
|
|
812
|
+
);
|
|
813
|
+
const messageId = providerMessageId(sent);
|
|
814
|
+
if (messageId) providerMessageIds.push(messageId);
|
|
815
|
+
}
|
|
816
|
+
finalSent = true;
|
|
817
|
+
textReceipt = createDeliveryReceipt({
|
|
818
|
+
deliveryId: messageId,
|
|
819
|
+
presentation: 'wecom-text',
|
|
820
|
+
providerMessageIds,
|
|
821
|
+
});
|
|
822
|
+
} catch (error) {
|
|
823
|
+
this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
|
|
594
824
|
}
|
|
595
|
-
finalSent = true;
|
|
596
|
-
} catch (error) {
|
|
597
|
-
this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
|
|
598
825
|
}
|
|
826
|
+
if (!finalSent) {
|
|
827
|
+
const providerMessageIds = await this.#sendActive(chatId, displayAnswer);
|
|
828
|
+
textReceipt = createDeliveryReceipt({
|
|
829
|
+
deliveryId: messageId,
|
|
830
|
+
presentation: 'wecom-text',
|
|
831
|
+
providerMessageIds,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
textSendError = error;
|
|
836
|
+
this.#logger.warn?.(
|
|
837
|
+
'[dsh-im:wecom] final text delivery failed; continuing with result files:',
|
|
838
|
+
error,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
|
842
|
+
const artifactDispatched = delivery.receipt?.artifacts?.some(
|
|
843
|
+
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
|
844
|
+
);
|
|
845
|
+
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
|
846
|
+
throw textSendError;
|
|
599
847
|
}
|
|
600
|
-
if (!finalSent) await this.#sendActive(chatId, answer);
|
|
601
848
|
await this.#state.markSeen(messageId);
|
|
602
849
|
this.#status.messagesReplied += 1;
|
|
603
850
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
604
851
|
this.#status.lastError = null;
|
|
852
|
+
return delivery.receipt;
|
|
605
853
|
} catch (error) {
|
|
606
854
|
if (error?.code === 'turn-stopped') {
|
|
607
855
|
if (streamStarted && streamId) {
|