mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
feat: add native image delivery across channels
This commit is contained in:
parent
dbc0a4b730
commit
3ec5f1eca7
31 changed files with 2754 additions and 846 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# 出站图片原生呈现落地方案
|
||||
|
||||
> 状态:待实施
|
||||
> 状态:已实施;九渠道真机验收通过
|
||||
>
|
||||
> 日期:2026-08-24
|
||||
>
|
||||
|
|
@ -23,6 +23,16 @@
|
|||
|
||||
不引入新的产物类型、基类、注册表、配置开关、回执版本或本地图片格式白名单。
|
||||
|
||||
### 1.1 实施结果(2026-08-24)
|
||||
|
||||
本方案已按上述最小边界落地:
|
||||
|
||||
- 新增 `src/channels/shared/semantic/artifact-delivery.mjs`,统一负责 `image/*` 选择、确定性失败降级、`artifact-delivery-uncertain`/Abort 防重复、回执合并和 Artifact 释放。
|
||||
- 共享 `TextHarnessBridge` 及飞书、微信、钉钉、企业微信、QQ Bridge 均改用该函数,不再各自维护图片识别与降级循环。
|
||||
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 接入各自原生图片发送;Slack、Discord 继续复用一次原生附件上传并显示图片预览。
|
||||
- `dsh_im_return_file`、`OutboundArtifact`、`DeliveryReceipt` v1 和现有 Bot 配置结构均保持不变。
|
||||
- `npm run check` 通过:1089/1089 测试通过,构建和发布包校验通过;独立代码复审未发现剩余 P0–P2 问题。
|
||||
|
||||
## 2. “语义核心 + 渠道原生适配”在本方案中的含义
|
||||
|
||||
| 层次 | 现有代码 | 本方案中的职责 |
|
||||
|
|
@ -327,6 +337,24 @@ try {
|
|||
|
||||
自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。
|
||||
|
||||
### 9.4 九渠道真机验收记录(2026-08-24)
|
||||
|
||||
统一使用工作区图片 `assets/logo-plugin-phone.png`(PNG,85,920 字节),由用户在每个渠道的真实会话中要求 Agent 调用 `dsh_im_return_file`。以下时间均为 Asia/Taipei:
|
||||
|
||||
| 渠道 | 实际发送路径 | 官方客户端可见结果 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| Telegram | `sendPhoto` | 03:07 出现一张原生图片消息 | 通过 |
|
||||
| Slack | 单次 external file upload | 03:08 在消息列中显示一张内联图片预览 | 通过 |
|
||||
| Discord | 单次 multipart attachment | 03:10 在 Bot 私信中显示一张内联图片附件 | 通过 |
|
||||
| 飞书 | `image.create` + `msg_type: image` | 03:11 在“梁子文件传送”会话中显示一张原生图片 | 通过 |
|
||||
| 微信 | CDN 图片上传 + `image_item` | 03:12 在 DeepSeek Harness 会话中显示一张图片气泡 | 通过 |
|
||||
| 钉钉 | image media + `sampleImageMsg` | 03:14 在“梁子来了”会话中显示一张原生图片,侧栏标记 `[Image]` | 通过 |
|
||||
| 企业微信 | `uploadMedia(image)` + image media message | 03:15 在“今天是梁子”会话中显示一张原生 `[Photo]` | 通过 |
|
||||
| QQ | SDK `sendImage` | 03:15 在 `winBot` 私聊中显示一张原生图片,侧栏标记 `[图片]` | 通过 |
|
||||
| WhatsApp | Baileys `{ image, mimetype }` | 03:16 在账号自聊中显示一张原生图片并保留引用上下文 | 通过 |
|
||||
|
||||
九个客户端均只出现一份图片产物,没有额外文件 fallback、重复图片或失败提示。事后状态检查中九个目标 Bot 均为 `connected` / `healthy`;除飞书状态投影不含本轮计数外,其余八个渠道均记录 1 条接收、1 条回复,Host 日志未出现 Artifact 投递失败、结果不确定或失败通知。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
- 九个渠道中的 PNG、JPEG 都能在原生客户端直接看到图片或图片附件预览。
|
||||
|
|
|
|||
330
lib/index.js
330
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -540,6 +540,93 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
}
|
||||
|
||||
async function sendArtifact({
|
||||
clientId,
|
||||
clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal,
|
||||
}, { uploadType, messageKey, createMessageParams }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A DingTalk file is required');
|
||||
}
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
let token;
|
||||
try {
|
||||
token = await accessToken({ clientId, clientSecret, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
||||
uploadUrl.searchParams.set('access_token', token);
|
||||
uploadUrl.searchParams.set('type', uploadType);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(uploaded);
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (uploadRejection || !mediaId) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'upload-rejected',
|
||||
'钉钉服务拒绝了文件上传。',
|
||||
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
||||
));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const messageBody = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: messageKey,
|
||||
msgParam: JSON.stringify(createMessageParams(mediaId, file)),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body: messageBody,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '文件消息发送',
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyDingtalkFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async beginRegistration({ signal } = {}) {
|
||||
const initialized = assertRegistrationOk(await requestJson(
|
||||
|
|
@ -842,89 +929,24 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ clientId, clientSecret, target, file, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A DingTalk file is required');
|
||||
}
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
const fileType = dingtalkFileType(file.fileName);
|
||||
let token;
|
||||
try {
|
||||
token = await accessToken({ clientId, clientSecret, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
||||
uploadUrl.searchParams.set('access_token', token);
|
||||
uploadUrl.searchParams.set('type', 'file');
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(uploaded);
|
||||
if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'upload-rejected',
|
||||
'钉钉服务拒绝了文件上传。',
|
||||
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
||||
));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const messageBody = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: 'sampleFile',
|
||||
msgParam: JSON.stringify({
|
||||
mediaId: uploaded.media_id,
|
||||
async sendFile(request) {
|
||||
return sendArtifact(request, {
|
||||
uploadType: 'file',
|
||||
messageKey: 'sampleFile',
|
||||
createMessageParams: (mediaId, file) => ({
|
||||
mediaId,
|
||||
fileName: file.fileName,
|
||||
fileType,
|
||||
fileType: dingtalkFileType(file.fileName),
|
||||
}),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body: messageBody,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '文件消息发送',
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyDingtalkFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
},
|
||||
|
||||
async sendImage(request) {
|
||||
return sendArtifact(request, {
|
||||
uploadType: 'image',
|
||||
messageKey: 'sampleImageMsg',
|
||||
createMessageParams: (mediaId) => ({ photoURL: mediaId }),
|
||||
});
|
||||
},
|
||||
|
||||
clearAccessToken(clientId) {
|
||||
|
|
|
|||
|
|
@ -35,14 +35,9 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -1084,70 +1079,39 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('DingTalk file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'dingtalk-file',
|
||||
providerMessageIds: dingtalkFileProviderIds(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const sendArtifact = async (method, file) => dingtalkFileProviderIds(
|
||||
await this.#api[method]({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
);
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
||||
channelKey: 'dingtalk',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#api.sendImage === 'function'
|
||||
? (file) => sendArtifact('sendImage', file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,14 +38,9 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
|
|
@ -1641,64 +1636,50 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#channel?.sendFile !== 'function') {
|
||||
const unavailable = new Error('Feishu file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(await this.#channel.sendFile(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeMessageId = null;
|
||||
try {
|
||||
noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
} catch {
|
||||
this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
if (receipts.length === 0) {
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
||||
channelKey: 'feishu',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#channel?.sendImage === 'function'
|
||||
? (file) => this.#channel.sendImage(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
})
|
||||
: undefined,
|
||||
sendFile: typeof this.#channel?.sendFile === 'function'
|
||||
? (file) => this.#channel.sendFile(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
})
|
||||
: undefined,
|
||||
sendFailureNotice: async (artifact, error) => ({
|
||||
messageId: await this.#send(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
}),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
if (!delivery.receipt) {
|
||||
return {
|
||||
receipt: createDeliveryReceipt({
|
||||
deliveryId: replyTo,
|
||||
presentation: 'feishu-files',
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
const receipt = receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
||||
presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, failureNoticeVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, message) {
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
|||
});
|
||||
}
|
||||
|
||||
function deliveryUuid(file, chatId) {
|
||||
function deliveryUuid(file, chatId, messageType) {
|
||||
const seed = `${file.deliveryKey}\u0000${chatId}`;
|
||||
const digest = createHash('sha256')
|
||||
.update(`${file.deliveryKey}\u0000${chatId}`)
|
||||
.update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
|
||||
.digest('hex')
|
||||
.slice(0, 40);
|
||||
return `dshim_${digest}`;
|
||||
|
|
@ -230,6 +231,29 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
|
||||
async sendFile(chatId, file, { replyTo, signal } = {}) {
|
||||
return this.#sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType: 'file',
|
||||
presentation: 'feishu-file',
|
||||
});
|
||||
}
|
||||
|
||||
async sendImage(chatId, file, { replyTo, signal } = {}) {
|
||||
return this.#sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType: 'image',
|
||||
presentation: 'feishu-image',
|
||||
});
|
||||
}
|
||||
|
||||
async #sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType,
|
||||
presentation,
|
||||
}) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
|
||||
if (!file || typeof file !== 'object'
|
||||
|
|
@ -243,13 +267,20 @@ export class VerifiedFeishuChannel {
|
|||
try {
|
||||
uploaded = await waitForFileOperation((operationSignal) => {
|
||||
operationSignal.throwIfAborted();
|
||||
const pending = this.#client.im.v1.file.create({
|
||||
data: {
|
||||
file_type: 'stream',
|
||||
file_name: file.fileName,
|
||||
file: file.bytes,
|
||||
},
|
||||
});
|
||||
const pending = messageType === 'image'
|
||||
? this.#client.im.v1.image.create({
|
||||
data: {
|
||||
image_type: 'message',
|
||||
image: file.bytes,
|
||||
},
|
||||
})
|
||||
: this.#client.im.v1.file.create({
|
||||
data: {
|
||||
file_type: 'stream',
|
||||
file_name: file.fileName,
|
||||
file: file.bytes,
|
||||
},
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
}, {
|
||||
|
|
@ -262,21 +293,25 @@ export class VerifiedFeishuChannel {
|
|||
throw fileDeliveryError('upload', error);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = uploaded?.file_key;
|
||||
if (typeof fileKey !== 'string' || !fileKey) {
|
||||
const resourceKey = messageType === 'image'
|
||||
? uploaded?.image_key ?? uploaded?.data?.image_key
|
||||
: uploaded?.file_key ?? uploaded?.data?.file_key;
|
||||
if (typeof resourceKey !== 'string' || !resourceKey) {
|
||||
throw fileDeliveryError('upload', undefined, uploaded?.code);
|
||||
}
|
||||
|
||||
const uuid = deliveryUuid(file, chatId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
const uuid = deliveryUuid(file, chatId, messageType);
|
||||
const content = JSON.stringify(messageType === 'image'
|
||||
? { image_key: resourceKey }
|
||||
: { file_key: resourceKey });
|
||||
const request = replyTo
|
||||
? {
|
||||
path: { message_id: replyTo },
|
||||
data: { msg_type: 'file', content, uuid },
|
||||
data: { msg_type: messageType, content, uuid },
|
||||
}
|
||||
: {
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: { receive_id: chatId, msg_type: 'file', content, uuid },
|
||||
data: { receive_id: chatId, msg_type: messageType, content, uuid },
|
||||
};
|
||||
const send = () => {
|
||||
const pending = replyTo
|
||||
|
|
@ -300,7 +335,7 @@ export class VerifiedFeishuChannel {
|
|||
operationSignal.throwIfAborted();
|
||||
|
||||
// Feishu documents 230049 as an uncertain asynchronous send result.
|
||||
// Reuse the same file_key and UUID once so the provider can deduplicate.
|
||||
// Reuse the same resource key and UUID once so the provider can deduplicate.
|
||||
if (Number(result?.code) === 230049) {
|
||||
result = await send();
|
||||
operationSignal.throwIfAborted();
|
||||
|
|
@ -324,7 +359,7 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
presentation,
|
||||
providerMessageIds: [messageId],
|
||||
artifacts: [{
|
||||
artifactId: file.artifactId,
|
||||
|
|
|
|||
|
|
@ -32,14 +32,11 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import { sendMarkdownReply } from './markdown-reply.mjs';
|
||||
|
|
@ -210,7 +207,7 @@ function qqArtifactError(error, { dispatched = false } = {}) {
|
|||
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
|
||||
else if (status === 413) wrapped.code = 'artifact-too-large';
|
||||
else if (status === 429) wrapped.code = 'artifact-rate-limited';
|
||||
else if (status === 400 || status === 404) {
|
||||
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
|
||||
wrapped.code = 'artifact-provider-rejected';
|
||||
} else {
|
||||
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
||||
|
|
@ -245,6 +242,68 @@ function waitWithSignal(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
/** Send one materialized artifact through QQ's native image message. */
|
||||
export async function sendQqImage(
|
||||
bot,
|
||||
target,
|
||||
file,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof bot?.sendImage !== 'function') {
|
||||
const unavailable = new Error('QQ image delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const pending = bot.sendImage(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{ onProgress: () => signal?.throwIfAborted() },
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQqFile(
|
||||
bot,
|
||||
target,
|
||||
file,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof bot?.sendFile !== 'function') {
|
||||
const unavailable = new Error('QQ file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const pending = bot.sendFile(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{
|
||||
fileName: file.fileName,
|
||||
onProgress: () => signal?.throwIfAborted(),
|
||||
},
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(message, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.senderId) === pending.actor
|
||||
|
|
@ -491,80 +550,35 @@ export class QqHarnessBridge {
|
|||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('QQ file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
let result;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
const pending = this.#bot.sendFile(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{
|
||||
fileName: file.fileName,
|
||||
onProgress: () => this.#signal?.throwIfAborted(),
|
||||
},
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = nonEmptyString(result?.message?.id);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'qq-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (rawError) {
|
||||
if (this.#signal?.aborted) throw rawError;
|
||||
const error = qqArtifactError(rawError);
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
||||
receipts,
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
||||
alwaysMerge: true,
|
||||
channelKey: 'qq',
|
||||
signal: this.#signal,
|
||||
sendImage: (file) => sendQqImage(this.#bot, target, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
sendFile: (file) => sendQqFile(this.#bot, target, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal file
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './delivery.mjs';
|
||||
|
||||
function unavailableError() {
|
||||
const error = new Error('Native file delivery is unavailable');
|
||||
error.code = 'artifact-provider-unavailable';
|
||||
return error;
|
||||
}
|
||||
|
||||
function isAbort(error, signal) {
|
||||
return signal?.aborted
|
||||
|| error?.name === 'AbortError'
|
||||
|| error?.code === 'ABORT_ERR';
|
||||
}
|
||||
|
||||
function providerIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value
|
||||
.filter((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
))
|
||||
.map(String))];
|
||||
}
|
||||
return providerMessageIdsFor(value);
|
||||
}
|
||||
|
||||
async function sendMaterializedArtifact(file, {
|
||||
sendFile,
|
||||
sendImage,
|
||||
signal,
|
||||
}) {
|
||||
if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
|
||||
try {
|
||||
return {
|
||||
presentation: 'image',
|
||||
result: await sendImage(file),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof sendFile !== 'function') throw unavailableError();
|
||||
return {
|
||||
presentation: 'file',
|
||||
result: await sendFile(file),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver registered artifacts with one shared image-first policy while keeping
|
||||
* provider protocol details inside the channel-supplied send closures.
|
||||
*/
|
||||
export async function deliverOutboundArtifacts({
|
||||
artifacts = [],
|
||||
baseReceipt = null,
|
||||
deliveryId,
|
||||
aggregatePresentation,
|
||||
alwaysMerge = false,
|
||||
channelKey,
|
||||
signal,
|
||||
sendFile,
|
||||
sendImage,
|
||||
sendFailureNotice,
|
||||
logger,
|
||||
}) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
let failureNoticeVisible = false;
|
||||
let artifactsSent = 0;
|
||||
let artifactSendErrors = 0;
|
||||
|
||||
let artifactIndex = 0;
|
||||
try {
|
||||
while (artifactIndex < artifacts.length) {
|
||||
const artifact = artifacts[artifactIndex];
|
||||
artifactIndex += 1;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const file = await materializeOutboundArtifact(artifact, { signal });
|
||||
signal?.throwIfAborted();
|
||||
const sent = await sendMaterializedArtifact(file, {
|
||||
sendFile,
|
||||
sendImage,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${channelKey}-${sent.presentation}`,
|
||||
providerMessageIds: providerIds(sent.result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
artifactsSent += 1;
|
||||
userVisible = true;
|
||||
} catch (error) {
|
||||
if (isAbort(error, signal)) throw error;
|
||||
artifactSendErrors += 1;
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let messageIds = [];
|
||||
if (typeof sendFailureNotice === 'function') {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const notice = await sendFailureNotice(artifact, error);
|
||||
signal?.throwIfAborted();
|
||||
messageIds = providerIds(notice);
|
||||
failureNoticeVisible = true;
|
||||
} catch (noticeError) {
|
||||
if (isAbort(noticeError, signal)) throw noticeError;
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds: messageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
|
||||
userVisible = true;
|
||||
}
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
while (artifactIndex < artifacts.length) {
|
||||
releaseOutboundArtifact(artifacts[artifactIndex]);
|
||||
artifactIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let receipt = null;
|
||||
if (receipts.length === 1 && !alwaysMerge) {
|
||||
[receipt] = receipts;
|
||||
} else if (receipts.length > 0) {
|
||||
receipt = mergeDeliveryReceipts({
|
||||
deliveryId: deliveryId
|
||||
?? baseReceipt?.deliveryId
|
||||
?? artifacts[0]?.deliveryKey,
|
||||
presentation: aggregatePresentation
|
||||
?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
|
||||
receipts,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
receipt,
|
||||
userVisible,
|
||||
failureNoticeVisible,
|
||||
artifactsSent,
|
||||
artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
|
@ -576,7 +576,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
|
|||
};
|
||||
const definition = Object.freeze({
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -654,7 +654,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
|
|||
ctx.systemPrompt.section({
|
||||
name: 'dsh-im:return-file',
|
||||
order: 115,
|
||||
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export function providerMessageIdsFor(value) {
|
|||
value.message?.ts,
|
||||
value.key?.id,
|
||||
value.data?.message_id,
|
||||
value.body?.msgid,
|
||||
value.body?.message_id,
|
||||
];
|
||||
const id = candidates.find((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|
|
|
|||
|
|
@ -32,14 +32,9 @@ import {
|
|||
harnessQuestionText,
|
||||
validHarnessQuestion,
|
||||
} from './harness-question.mjs';
|
||||
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -339,73 +334,29 @@ export class TextHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('Native file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const result = await this.#bot.sendFile(target, file);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${this.#descriptor.key}-file`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
let noticeSent = false;
|
||||
try {
|
||||
const notice = await this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
);
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
noticeSent = true;
|
||||
} catch {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
|
||||
);
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt
|
||||
? `${this.#descriptor.key}-text-and-files`
|
||||
: `${this.#descriptor.key}-files`,
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
channelKey: this.#descriptor.key,
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#bot.sendImage === 'function'
|
||||
? (file) => this.#bot.sendImage(target, file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#bot.sendFile === 'function'
|
||||
? (file) => this.#bot.sendFile(target, file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
|
|
|
|||
|
|
@ -35,33 +35,33 @@ function preserveProviderMetadata(target, source) {
|
|||
return target;
|
||||
}
|
||||
|
||||
function telegramArtifactProviderError(cause) {
|
||||
function telegramArtifactProviderError(cause, mediaLabel = 'document') {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
const message = cleanString(cause?.message) ?? '';
|
||||
let code = 'artifact-provider-rejected';
|
||||
let summary = 'Telegram rejected the document.';
|
||||
let summary = `Telegram rejected the ${mediaLabel}.`;
|
||||
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
|
||||
code = 'artifact-permission-required';
|
||||
summary = 'Telegram denied permission to send the document.';
|
||||
summary = `Telegram denied permission to send the ${mediaLabel}.`;
|
||||
} else if (providerCode === 413 || status === 413
|
||||
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
|
||||
code = 'artifact-too-large';
|
||||
summary = 'The document exceeds Telegram\'s size limit.';
|
||||
summary = `The ${mediaLabel} exceeds Telegram's size limit.`;
|
||||
} else if (providerCode === 429 || status === 429) {
|
||||
code = 'artifact-rate-limited';
|
||||
summary = 'Telegram rate-limited document delivery.';
|
||||
summary = `Telegram rate-limited ${mediaLabel} delivery.`;
|
||||
} else if (providerCode >= 500 || status >= 500) {
|
||||
code = 'artifact-delivery-uncertain';
|
||||
summary = 'Telegram document delivery result is uncertain.';
|
||||
summary = `Telegram ${mediaLabel} delivery result is uncertain.`;
|
||||
}
|
||||
const error = new Error(summary, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainTelegramDelivery(cause) {
|
||||
const error = new Error('Telegram document delivery result is uncertain', { cause });
|
||||
function uncertainTelegramDelivery(cause, mediaLabel = 'document') {
|
||||
const error = new Error(`Telegram ${mediaLabel} delivery result is uncertain`, { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
|
@ -184,15 +184,41 @@ export class TelegramApi {
|
|||
}
|
||||
|
||||
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#sendArtifact('sendDocument', 'document', 'document', {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPhoto({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#sendArtifact('sendPhoto', 'photo', 'photo', {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async #sendArtifact(method, fieldName, mediaLabel, {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
}) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Telegram document is required');
|
||||
throw new TypeError(`A Telegram ${mediaLabel} is required`);
|
||||
}
|
||||
const payload = new FormData();
|
||||
payload.append('chat_id', String(chatId));
|
||||
payload.append(
|
||||
'document',
|
||||
fieldName,
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
|
|
@ -206,7 +232,7 @@ export class TelegramApi {
|
|||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
try {
|
||||
return await this.#call('sendDocument', payload, {
|
||||
return await this.#call(method, payload, {
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
multipart: true,
|
||||
|
|
@ -214,9 +240,9 @@ export class TelegramApi {
|
|||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code?.startsWith?.('telegram-')) {
|
||||
throw telegramArtifactProviderError(error);
|
||||
throw telegramArtifactProviderError(error, mediaLabel);
|
||||
}
|
||||
throw uncertainTelegramDelivery(error);
|
||||
throw uncertainTelegramDelivery(error, mediaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,16 @@ export class TelegramBotClient {
|
|||
});
|
||||
}
|
||||
|
||||
sendImage(target, file) {
|
||||
return this.#api.sendPhoto({
|
||||
chatId: target.chatId,
|
||||
file,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 4_000,
|
||||
|
|
|
|||
|
|
@ -31,15 +31,10 @@ import {
|
|||
inboundFileUserMessage,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -353,6 +348,71 @@ function wecomArtifactError(error, { dispatched = false } = {}) {
|
|||
return wrapped;
|
||||
}
|
||||
|
||||
async function sendWecomMedia(
|
||||
client,
|
||||
chatId,
|
||||
file,
|
||||
mediaType,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof client?.uploadMedia !== 'function'
|
||||
|| typeof client?.sendMediaMessage !== 'function') {
|
||||
const unavailable = new Error(`Enterprise WeChat ${mediaType} delivery is unavailable`);
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
let uploaded;
|
||||
try {
|
||||
const pending = client.uploadMedia(file.bytes, {
|
||||
type: mediaType,
|
||||
filename: file.fileName,
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
uploaded = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw wecomArtifactError(error);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (!mediaId) {
|
||||
const rejected = new Error(`Enterprise WeChat ${mediaType} upload returned no media id`);
|
||||
rejected.code = 'artifact-provider-rejected';
|
||||
throw rejected;
|
||||
}
|
||||
|
||||
let sent;
|
||||
try {
|
||||
const pending = client.sendMediaMessage(chatId, mediaType, mediaId);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
sent = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw wecomArtifactError(error, { dispatched: true });
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
||||
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
throw wecomArtifactError({ providerCode });
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Send one materialized artifact through Enterprise WeChat's native image message. */
|
||||
export function sendWecomImage(client, chatId, file, options) {
|
||||
return sendWecomMedia(client, chatId, file, 'image', options);
|
||||
}
|
||||
|
||||
function sendWecomFile(client, chatId, file, options) {
|
||||
return sendWecomMedia(client, chatId, file, 'file', options);
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
|
||||
|
|
@ -650,98 +710,35 @@ export class WecomHarnessBridge {
|
|||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#client.uploadMedia !== 'function'
|
||||
|| typeof this.#client.sendMediaMessage !== 'function') {
|
||||
const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
let uploaded;
|
||||
try {
|
||||
const pending = this.#client.uploadMedia(file.bytes, {
|
||||
type: 'file',
|
||||
filename: file.fileName,
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
uploaded = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (!mediaId) {
|
||||
const rejected = new Error('Enterprise WeChat upload returned no media id');
|
||||
rejected.code = 'artifact-provider-rejected';
|
||||
throw rejected;
|
||||
}
|
||||
let sent;
|
||||
try {
|
||||
const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
sent = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
||||
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
throw wecomArtifactError({ providerCode });
|
||||
}
|
||||
const messageId = providerMessageId(sent);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'wecom-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
providerMessageIds = await this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
);
|
||||
failureNoticeVisible = true;
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
||||
receipts,
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
||||
alwaysMerge: true,
|
||||
channelKey: 'wecom',
|
||||
signal: this.#signal,
|
||||
sendImage: (file) => sendWecomImage(this.#client, chatId, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
sendFile: (file) => sendWecomFile(this.#client, chatId, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -457,6 +457,123 @@ function validateLoginResponse(value) {
|
|||
export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
|
||||
async function sendArtifact({
|
||||
baseUrl,
|
||||
token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal,
|
||||
}, { mediaType, createItem }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient || !file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('toUserId and a file are required');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = randomBytes(16).toString('hex');
|
||||
const aesKey = randomBytes(16);
|
||||
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
||||
let upload;
|
||||
try {
|
||||
upload = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getuploadurl',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
filekey: fileKey,
|
||||
media_type: mediaType,
|
||||
to_user_id: recipient,
|
||||
rawsize: file.bytes.byteLength,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(upload);
|
||||
if (uploadRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'upload-url-rejected',
|
||||
'微信服务拒绝了文件上传请求。',
|
||||
{ providerCode: uploadRejection },
|
||||
));
|
||||
}
|
||||
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
||||
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
||||
let downloadParam;
|
||||
try {
|
||||
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
||||
?? randomUUID();
|
||||
const clientIdSeed = mediaType === 3 ? deliverySeed : `${deliverySeed}\u0000${mediaType}`;
|
||||
const clientId = `dsh-weixin-${createHash('sha256')
|
||||
.update(clientIdSeed)
|
||||
.digest('hex')
|
||||
.slice(0, 32)}`;
|
||||
const media = {
|
||||
encrypt_query_param: downloadParam,
|
||||
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
};
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
||||
},
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyWeixinFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'send-rejected',
|
||||
'微信服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
inboundImages(message) {
|
||||
return extractWeixinImages(message, { fetchImpl });
|
||||
|
|
@ -549,115 +666,31 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient || !file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('toUserId and a file are required');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = randomBytes(16).toString('hex');
|
||||
const aesKey = randomBytes(16);
|
||||
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
||||
let upload;
|
||||
try {
|
||||
upload = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getuploadurl',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
filekey: fileKey,
|
||||
media_type: 3,
|
||||
to_user_id: recipient,
|
||||
rawsize: file.bytes.byteLength,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
base_info: baseInfo(),
|
||||
async sendFile(request) {
|
||||
return sendArtifact(request, {
|
||||
mediaType: 3,
|
||||
createItem: ({ file, media }) => ({
|
||||
type: 4,
|
||||
file_item: {
|
||||
media,
|
||||
file_name: file.fileName,
|
||||
len: String(file.bytes.byteLength),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(upload);
|
||||
if (uploadRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'upload-url-rejected',
|
||||
'微信服务拒绝了文件上传请求。',
|
||||
{ providerCode: uploadRejection },
|
||||
));
|
||||
}
|
||||
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
||||
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
||||
let downloadParam;
|
||||
try {
|
||||
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
||||
?? randomUUID();
|
||||
const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: downloadParam,
|
||||
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: file.fileName,
|
||||
len: String(file.bytes.byteLength),
|
||||
},
|
||||
}],
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
||||
},
|
||||
base_info: baseInfo(),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async sendImage(request) {
|
||||
return sendArtifact(request, {
|
||||
mediaType: 1,
|
||||
createItem: ({ media, ciphertextSize }) => ({
|
||||
type: 2,
|
||||
image_item: {
|
||||
media,
|
||||
mid_size: ciphertextSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyWeixinFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'send-rejected',
|
||||
'微信服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async notifyStart({ baseUrl, token, signal }) {
|
||||
|
|
|
|||
|
|
@ -38,14 +38,9 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -869,73 +864,40 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('Weixin file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'weixin-file',
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
contextToken,
|
||||
runId,
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const sendArtifact = (method, file) => this.#api[method]({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
||||
channelKey: 'weixin',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#api.sendImage === 'function'
|
||||
? (file) => sendArtifact('sendImage', file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
contextToken,
|
||||
runId,
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,12 +313,31 @@ function waitWithSignal(promise, signal) {
|
|||
|
||||
function uncertainArtifactDelivery(error) {
|
||||
if (error?.code === 'artifact-delivery-uncertain') return error;
|
||||
const uncertain = new Error('WhatsApp could not confirm result file delivery.');
|
||||
const uncertain = new Error('WhatsApp could not confirm artifact delivery.');
|
||||
uncertain.code = 'artifact-delivery-uncertain';
|
||||
uncertain.cause = error;
|
||||
return uncertain;
|
||||
}
|
||||
|
||||
function whatsappArtifactError(error) {
|
||||
if (error?.code?.startsWith?.('artifact-')) return error;
|
||||
const status = error?.output?.statusCode
|
||||
?? error?.data?.statusCode
|
||||
?? error?.statusCode;
|
||||
let code;
|
||||
if (status === 401 || status === 403) code = 'artifact-permission-required';
|
||||
else if (status === 413) code = 'artifact-too-large';
|
||||
else if (status === 429) code = 'artifact-rate-limited';
|
||||
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
|
||||
code = 'artifact-provider-rejected';
|
||||
}
|
||||
if (!code) return uncertainArtifactDelivery(error);
|
||||
const wrapped = new Error('WhatsApp rejected artifact delivery.');
|
||||
wrapped.code = code;
|
||||
wrapped.cause = error;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export class WhatsappBotClient {
|
||||
#socket;
|
||||
#outboundIds;
|
||||
|
|
@ -354,14 +373,32 @@ export class WhatsappBotClient {
|
|||
}
|
||||
|
||||
async sendFile(target, file) {
|
||||
return this.#sendArtifact(target, file, {
|
||||
document: file.bytes,
|
||||
mimetype: file.mediaType ?? 'application/octet-stream',
|
||||
fileName: file.fileName,
|
||||
}, 'file');
|
||||
}
|
||||
|
||||
async sendImage(target, file) {
|
||||
return this.#sendArtifact(target, file, {
|
||||
image: file.bytes,
|
||||
mimetype: file.mediaType ?? 'image/jpeg',
|
||||
}, 'image');
|
||||
}
|
||||
|
||||
async #sendArtifact(target, file, content, presentation) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#stopTyping(target.jid);
|
||||
this.#signal?.throwIfAborted();
|
||||
const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
|
||||
? file.deliveryKey
|
||||
: file.artifactId;
|
||||
const messageId = typeof deliverySeed === 'string' && deliverySeed
|
||||
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 20).toUpperCase()
|
||||
const messageIdSeed = presentation === 'image'
|
||||
? `${deliverySeed}:image`
|
||||
: deliverySeed;
|
||||
const messageId = typeof messageIdSeed === 'string' && messageIdSeed
|
||||
? createHash('sha256').update(messageIdSeed).digest('hex').slice(0, 20).toUpperCase()
|
||||
: undefined;
|
||||
const options = {
|
||||
...(target.quoted ? { quoted: target.quoted } : {}),
|
||||
|
|
@ -375,11 +412,7 @@ export class WhatsappBotClient {
|
|||
try {
|
||||
const pending = this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{
|
||||
document: file.bytes,
|
||||
mimetype: file.mediaType ?? 'application/octet-stream',
|
||||
fileName: file.fileName,
|
||||
},
|
||||
content,
|
||||
options,
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
|
|
@ -390,7 +423,7 @@ export class WhatsappBotClient {
|
|||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw uncertainArtifactDelivery(error);
|
||||
throw whatsappArtifactError(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
|
|
|
|||
332
test/artifact-delivery.test.mjs
Normal file
332
test/artifact-delivery.test.mjs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
} from '../src/channels/shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../src/channels/shared/semantic/artifact-delivery.mjs';
|
||||
import { createDeliveryReceipt } from '../src/channels/shared/semantic/delivery.mjs';
|
||||
|
||||
let fixtureId = 0;
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
fixtureId += 1;
|
||||
const suffix = String(fixtureId);
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-delivery-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `artifact-${suffix}` });
|
||||
t.after(() => registry.clear());
|
||||
const sessionId = `session-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const execution = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, execution);
|
||||
tool.onResult(execution, { isError: false });
|
||||
return registry.take(sessionId, 1)[0];
|
||||
}
|
||||
|
||||
function rejected(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function assertReleased(artifact) {
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error?.code === 'artifact-invalid',
|
||||
);
|
||||
}
|
||||
|
||||
test('non-image artifacts use the existing file sender once', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'report.txt', 'report');
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
deliveryId: 'reply-1',
|
||||
channelKey: 'test',
|
||||
sendImage: async () => calls.push('image'),
|
||||
sendFile: async (file) => {
|
||||
calls.push(`file:${file.fileName}`);
|
||||
return { messageId: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['file:report.txt']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['file-1']);
|
||||
assert.equal(delivery.artifactsSent, 1);
|
||||
assert.equal(delivery.artifactSendErrors, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('image artifacts prefer the native image sender', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1, 2, 3]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async (file) => {
|
||||
calls.push(`image:${file.mediaType}`);
|
||||
return { id: 'image-1' };
|
||||
},
|
||||
sendFile: async () => calls.push('file'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image:image/png']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-image');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['image-1']);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('an image uses file delivery when the channel has no image sender', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.jpg', Buffer.from([1]));
|
||||
let fileCalls = 0;
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
return { id: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fileCalls, 1);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a definitive native-image rejection falls back to file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
calls.push('image');
|
||||
throw rejected('artifact-provider-rejected');
|
||||
},
|
||||
sendFile: async () => {
|
||||
calls.push('file');
|
||||
return { id: 'file-after-image' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image', 'file']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
assert.equal(delivery.artifactsSent, 1);
|
||||
assert.equal(delivery.artifactSendErrors, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a failed file fallback produces one failure receipt and one safe notice', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
calls.push('image');
|
||||
throw rejected('artifact-provider-rejected');
|
||||
},
|
||||
sendFile: async () => {
|
||||
calls.push('file');
|
||||
throw rejected('artifact-provider-failed');
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
calls.push('notice');
|
||||
return { messageId: 'notice-after-fallback' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image', 'file', 'notice']);
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['notice-after-fallback']);
|
||||
assert.deepEqual(delivery.receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'failed',
|
||||
reason: 'artifact-provider-failed',
|
||||
}]);
|
||||
assert.equal(delivery.artifactsSent, 0);
|
||||
assert.equal(delivery.artifactSendErrors, 1);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('an uncertain native-image result never sends a duplicate file', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.gif', Buffer.from([1]));
|
||||
let fileCalls = 0;
|
||||
let notices = 0;
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
throw rejected('artifact-delivery-uncertain');
|
||||
},
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
notices += 1;
|
||||
return { messageId: 'notice-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fileCalls, 0);
|
||||
assert.equal(notices, 1);
|
||||
assert.deepEqual(delivery.receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
assert.equal(delivery.userVisible, true);
|
||||
assert.equal(delivery.artifactSendErrors, 1);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('abort during native image delivery stops without fallback or notice', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1]));
|
||||
const controller = new AbortController();
|
||||
let fileCalls = 0;
|
||||
let notices = 0;
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendImage: async () => {
|
||||
controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
},
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
notices += 1;
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.equal(fileCalls, 0);
|
||||
assert.equal(notices, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a pre-aborted delivery releases every unprocessed artifact', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second');
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let sends = 0;
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [first, second],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async () => { sends += 1; },
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.equal(sends, 0);
|
||||
await assertReleased(first);
|
||||
await assertReleased(second);
|
||||
});
|
||||
|
||||
test('an abort while processing multiple artifacts releases the remaining artifacts', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second');
|
||||
const controller = new AbortController();
|
||||
const calls = [];
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [first, second],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async (file) => {
|
||||
calls.push(file.fileName);
|
||||
controller.abort();
|
||||
return { id: 'provider-may-have-sent' };
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.deepEqual(calls, ['first.txt']);
|
||||
await assertReleased(first);
|
||||
await assertReleased(second);
|
||||
});
|
||||
|
||||
test('abort during a failure notice remains terminal and releases the artifact', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.txt', 'result');
|
||||
const controller = new AbortController();
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async () => {
|
||||
throw rejected('artifact-provider-failed');
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
controller.abort();
|
||||
return { messageId: 'must-not-be-receipted' };
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('mixed artifacts preserve order and merge existing receipt semantics', async (t) => {
|
||||
const image = await committedArtifact(t, 'first.png', Buffer.from([1]));
|
||||
const file = await committedArtifact(t, 'second.txt', 'two');
|
||||
const calls = [];
|
||||
const baseReceipt = createDeliveryReceipt({
|
||||
deliveryId: 'reply-mixed',
|
||||
presentation: 'test-text',
|
||||
providerMessageIds: ['text-1'],
|
||||
});
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [image, file],
|
||||
baseReceipt,
|
||||
deliveryId: 'reply-mixed',
|
||||
channelKey: 'test',
|
||||
sendImage: async (materialized) => {
|
||||
calls.push(materialized.fileName);
|
||||
return { id: 'image-1' };
|
||||
},
|
||||
sendFile: async (materialized) => {
|
||||
calls.push(materialized.fileName);
|
||||
return { id: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['first.png', 'second.txt']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-text-and-files');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['text-1', 'image-1', 'file-1']);
|
||||
assert.deepEqual(delivery.receipt.artifacts.map(({ outcome }) => outcome), ['sent', 'sent']);
|
||||
assert.equal(delivery.artifactsSent, 2);
|
||||
await assertReleased(image);
|
||||
await assertReleased(file);
|
||||
});
|
||||
|
|
@ -173,6 +173,58 @@ test('DingTalk uploads and sends a native file message to the exact robot conver
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native image message to the exact robot user', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@image-one', type: 'image' });
|
||||
}
|
||||
return jsonResponse({ processQueryKey: 'image-query-one' });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const response = await api.sendImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'user',
|
||||
robotCode: 'robot-code',
|
||||
userId: 'user-one',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('dingtalk-image'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.processQueryKey, 'image-query-one');
|
||||
const uploadUrl = new URL(calls[1].url);
|
||||
assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com');
|
||||
assert.equal(uploadUrl.pathname, '/media/upload');
|
||||
assert.equal(uploadUrl.searchParams.get('access_token'), 'image-access-token');
|
||||
assert.equal(uploadUrl.searchParams.get('type'), 'image');
|
||||
const media = calls[1].options.body.get('media');
|
||||
assert.equal(media.name, 'result.png');
|
||||
assert.equal(media.type, 'image/png');
|
||||
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-image');
|
||||
|
||||
assert.equal(
|
||||
calls[2].url,
|
||||
`${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`,
|
||||
);
|
||||
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'image-access-token');
|
||||
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||
robotCode: 'robot-code',
|
||||
msgKey: 'sampleImageMsg',
|
||||
msgParam: JSON.stringify({ photoURL: '@image-one' }),
|
||||
userIds: ['user-one'],
|
||||
});
|
||||
});
|
||||
|
||||
function dingtalkFileRequest(overrides = {}) {
|
||||
return {
|
||||
clientId: 'ding-client',
|
||||
|
|
@ -243,6 +295,24 @@ test('DingTalk marks every ambiguous robot file send result as uncertain', async
|
|||
}
|
||||
});
|
||||
|
||||
test('DingTalk keeps ambiguous native image sends in the uncertain bucket', async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => { throw new TypeError('private socket detail'); }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.sendImage(dingtalkFileRequest({
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('dingtalk-image-error'),
|
||||
},
|
||||
})),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
|
|
|
|||
|
|
@ -339,6 +339,110 @@ test('DingTalk returns a registered result file through the native robot convers
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_BYTES,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'dingtalk-image',
|
||||
expectedProviderIds: ['dingtalk-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'dingtalk-file',
|
||||
expectedProviderIds: ['dingtalk-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'dingtalk-file',
|
||||
expectedProviderIds: ['dingtalk-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', `session-image-route-${index}`);
|
||||
const calls = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => { throw new Error('text intentionally unavailable'); },
|
||||
sendImage: async ({ file, target }) => {
|
||||
calls.push('image');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
assert.deepEqual(target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return { processQueryKey: 'dingtalk-native-image' };
|
||||
},
|
||||
sendFile: async ({ file, target }) => {
|
||||
calls.push('file');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.deepEqual(target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
return { processQueryKey: 'dingtalk-native-file' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`dingtalk-image-route-${index}`, '生成产物', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'dingtalk-text-failed.pdf', 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
|
|
|
|||
|
|
@ -153,6 +153,45 @@ test('Discord API uploads a result file as a native attachment and preserves the
|
|||
assert.equal(Buffer.from(await attachment.arrayBuffer()).toString(), '<p>discord-result</p>');
|
||||
});
|
||||
|
||||
test('Discord sends PNG and JPEG artifacts as one native inline-preview attachment each', async () => {
|
||||
const requests = [];
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return jsonResponse({ id: '987654321012345681', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, image] of [{
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
}, {
|
||||
fileName: 'result.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
bytes: Buffer.from([0xff, 0xd8, 0xff]),
|
||||
}].entries()) {
|
||||
await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: {
|
||||
artifactId: `artifact-discord-image-${index}`,
|
||||
deliveryKey: `session:turn:artifact-discord-image-${index}`,
|
||||
...image,
|
||||
},
|
||||
});
|
||||
|
||||
const request = requests[index];
|
||||
assert.match(request.url.pathname, /channels\/123456789012345678\/messages$/);
|
||||
const payload = JSON.parse(request.options.body.get('payload_json'));
|
||||
assert.deepEqual(payload.attachments, [{ id: 0, filename: image.fileName }]);
|
||||
const attachment = request.options.body.get('files[0]');
|
||||
assert.equal(attachment.name, image.fileName);
|
||||
assert.equal(attachment.type, image.mediaType);
|
||||
}
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('Discord attachment retry reuses one FormData body and one stable nonce', async () => {
|
||||
const bodies = [];
|
||||
const nonces = [];
|
||||
|
|
|
|||
|
|
@ -1977,6 +1977,121 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_1X1,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'feishu-image',
|
||||
expectedProviderIds: ['om-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(
|
||||
subtest,
|
||||
scenario.fileName,
|
||||
scenario.content,
|
||||
`bridge-image-route-${index}`,
|
||||
);
|
||||
const calls = [];
|
||||
const status = bridgeStatus();
|
||||
const resultFor = (file, presentation, providerMessageId) => ({
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation,
|
||||
providerMessageIds: [providerMessageId],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
});
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => { throw new Error('text intentionally unavailable'); }),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
sendImage: async (chatId, file, options) => {
|
||||
calls.push('image');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return resultFor(file, 'feishu-image', 'om-native-image');
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
calls.push('file');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
return resultFor(file, 'feishu-file', 'om-native-file');
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: stateFixture([
|
||||
['p2p:ou_user', `session-feishu-image-route-${index}`],
|
||||
]).state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-feishu-image-route-${index}`, '生成产物'));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function fakeClient(overrides = {}) {
|
|||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
fileUploads: [],
|
||||
imageUploads: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
|
|
@ -35,6 +36,12 @@ function fakeClient(overrides = {}) {
|
|||
return { file_key: 'file-key-test' };
|
||||
},
|
||||
},
|
||||
image: {
|
||||
create: async (request) => {
|
||||
calls.imageUploads.push(request);
|
||||
return { data: { image_key: 'image-key-test' } };
|
||||
},
|
||||
},
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
|
|
@ -62,6 +69,7 @@ function fakeClient(overrides = {}) {
|
|||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile;
|
||||
if (overrides.uploadImage) client.im.v1.image.create = overrides.uploadImage;
|
||||
if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
|
||||
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
|
||||
return { client, calls };
|
||||
|
|
@ -172,6 +180,40 @@ test('VerifiedFeishuChannel uploads a materialized result and replies with a nat
|
|||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads an image and replies with a native image message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-image',
|
||||
deliveryKey: 'delivery-image',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
size: 12,
|
||||
bytes: Buffer.from('png contents'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendImage('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 0);
|
||||
assert.equal(calls.imageUploads.length, 1);
|
||||
assert.deepEqual(calls.imageUploads[0].data, {
|
||||
image_type: 'message',
|
||||
image: file.bytes,
|
||||
});
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'image');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { image_key: 'image-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-image',
|
||||
presentation: 'feishu-image',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-image', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
createMessage: async (request) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { qqInboundMessage, QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import {
|
||||
qqInboundMessage,
|
||||
QqHarnessBridge,
|
||||
sendQqImage,
|
||||
} from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -96,6 +100,158 @@ const PNG_BYTES = Buffer.from([
|
|||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
test('QQ native image adapter delegates to the SDK sendImage method', async () => {
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'image-request' };
|
||||
const calls = [];
|
||||
const result = { message: { id: 'qq-image-1' } };
|
||||
const returned = await sendQqImage({
|
||||
sendImage: async (replyTarget, source, options) => {
|
||||
calls.push({ replyTarget, source, options });
|
||||
return result;
|
||||
},
|
||||
}, target, {
|
||||
artifactId: 'image-artifact',
|
||||
deliveryKey: 'image-delivery',
|
||||
fileName: 'diagram.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: PNG_BYTES,
|
||||
});
|
||||
|
||||
assert.equal(returned, result);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].replyTarget, target);
|
||||
assert.deepEqual(calls[0].source.buffer, PNG_BYTES);
|
||||
assert.equal(typeof calls[0].options.onProgress, 'function');
|
||||
});
|
||||
|
||||
test('QQ native image adapter preserves definite SDK rejections', async () => {
|
||||
await assert.rejects(sendQqImage({
|
||||
sendImage: async () => {
|
||||
const error = new Error('unsupported image');
|
||||
error.httpStatus = 415;
|
||||
throw error;
|
||||
},
|
||||
}, { scope: 'c2c', targetId: 'owner-openid' }, {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_BYTES,
|
||||
}), (error) => error.code === 'artifact-provider-rejected');
|
||||
});
|
||||
|
||||
test('QQ native image adapter marks an unacknowledged SDK send as uncertain', async () => {
|
||||
await assert.rejects(sendQqImage({
|
||||
sendImage: async () => new Promise(() => {}),
|
||||
}, { scope: 'c2c', targetId: 'owner-openid' }, {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_BYTES,
|
||||
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('QQ sends an image artifact with the native SDK image method', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'native.png', PNG_BYTES, 'native-image');
|
||||
const images = [];
|
||||
let files = 0;
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-native-image' };
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => ({ id: 'qq-text' }),
|
||||
sendImage: async (replyTarget, source, options) => {
|
||||
images.push({ replyTarget, bytes: Buffer.from(source.buffer), options });
|
||||
return { message: { id: 'qq-image' } };
|
||||
},
|
||||
sendFile: async () => { files += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-native-image']]).state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({
|
||||
messageId: 'qq-native-image',
|
||||
replyTarget: target,
|
||||
}));
|
||||
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(images[0].replyTarget, target);
|
||||
assert.deepEqual(images[0].bytes, PNG_BYTES);
|
||||
assert.equal(typeof images[0].options.onProgress, 'function');
|
||||
assert.equal(files, 0);
|
||||
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('QQ falls back to a native file after a definite native-image rejection', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.png', PNG_BYTES, 'image-fallback');
|
||||
const order = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => ({ id: 'qq-text' }),
|
||||
sendImage: async () => {
|
||||
order.push('image');
|
||||
const error = new Error('unsupported image');
|
||||
error.httpStatus = 422;
|
||||
throw error;
|
||||
},
|
||||
sendFile: async (_target, source, options) => {
|
||||
order.push(`file:${options.fileName}:${source.buffer.length}`);
|
||||
return { message: { id: 'qq-file' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-image-fallback']]).state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-image-fallback' }));
|
||||
|
||||
assert.deepEqual(order, [`image`, `file:fallback.png:${PNG_BYTES.length}`]);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('QQ does not file-fallback after an uncertain native image send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.png', PNG_BYTES, 'image-uncertain');
|
||||
const texts = [];
|
||||
let files = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
texts.push(text);
|
||||
return { id: `qq-text-${texts.length}` };
|
||||
},
|
||||
sendImage: async () => new Promise(() => {}),
|
||||
sendFile: async () => { files += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-image-uncertain']]).state,
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-image-uncertain' }));
|
||||
|
||||
assert.equal(files, 0);
|
||||
assert.match(texts.at(-1), /发送结果未能确认.*不要立即重试/);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'unknown');
|
||||
});
|
||||
|
||||
test('QQ normalizes protocol-relative ordinary-file URLs and lets the CDN redirect', async () => {
|
||||
const bytes = Buffer.from('qq-native-file');
|
||||
const calls = [];
|
||||
|
|
|
|||
|
|
@ -184,6 +184,51 @@ test('Slack API completes the native external-upload flow in the original thread
|
|||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:write\n/);
|
||||
});
|
||||
|
||||
test('Slack sends PNG and JPEG artifacts through one native image-preview upload flow each', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/IMAGE',
|
||||
file_id: `FIMAGE${calls.length}`,
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
const completed = JSON.parse(options.body);
|
||||
return jsonResponse({ ok: true, files: completed.files });
|
||||
},
|
||||
});
|
||||
|
||||
for (const image of [{
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
}, {
|
||||
fileName: 'result.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
bytes: Buffer.from([0xff, 0xd8, 0xff]),
|
||||
}]) {
|
||||
const offset = calls.length;
|
||||
await api.uploadFile({ channelId: 'C12345678', file: image });
|
||||
|
||||
assert.equal(calls.length, offset + 3);
|
||||
assert.equal(
|
||||
calls[offset].options.body,
|
||||
`filename=${image.fileName}&length=${image.bytes.length}`,
|
||||
);
|
||||
assert.equal(calls[offset + 1].options.headers['content-type'], image.mediaType);
|
||||
assert.deepEqual(JSON.parse(calls[offset + 2].options.body).files, [{
|
||||
id: `FIMAGE${offset + 1}`,
|
||||
title: image.fileName,
|
||||
}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack file preparation maps missing scope and pre-delivery failures without claiming uncertainty', async () => {
|
||||
const missingScopeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
TELEGRAM_ACCESS_MODES,
|
||||
TelegramConfigStore,
|
||||
|
|
@ -19,6 +24,7 @@ import {
|
|||
} from '../../../src/channels/telegram/telegram-api.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import {
|
||||
TelegramBotClient,
|
||||
TelegramRuntime,
|
||||
TELEGRAM_COMMAND_MENU,
|
||||
normalizeTelegramUpdate,
|
||||
|
|
@ -87,6 +93,42 @@ async function bounded(promise, message, timeoutMs = 1_000) {
|
|||
}
|
||||
}
|
||||
|
||||
async function committedTelegramArtifact(t, {
|
||||
suffix,
|
||||
fileName,
|
||||
content,
|
||||
}) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-telegram-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-telegram-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const execution = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, execution);
|
||||
tool.onResult(execution, { isError: false });
|
||||
return registry.take(sessionId, 1)[0];
|
||||
}
|
||||
|
||||
test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => {
|
||||
assert.equal(validTelegramToken(TOKEN), true);
|
||||
assert.equal(validTelegramToken('short'), false);
|
||||
|
|
@ -242,6 +284,99 @@ test('Telegram API uploads a result file as a native document in the same topic
|
|||
assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result');
|
||||
});
|
||||
|
||||
test('Telegram API uploads a result image as a native photo in the same topic and reply chain', async () => {
|
||||
let request;
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ ok: true, result: { message_id: 902 } });
|
||||
},
|
||||
});
|
||||
const result = await api.sendPhoto({
|
||||
chatId: -100123,
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('telegram-image'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.message_id, 902);
|
||||
assert.match(request.url.pathname, /sendPhoto$/);
|
||||
assert.equal(request.options.method, 'POST');
|
||||
assert.equal(request.options.headers, undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
assert.equal(request.options.body.get('chat_id'), '-100123');
|
||||
assert.equal(request.options.body.get('message_thread_id'), '55');
|
||||
assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), {
|
||||
message_id: 44,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
const photo = request.options.body.get('photo');
|
||||
assert.equal(photo.name, 'result.png');
|
||||
assert.equal(photo.type, 'image/png');
|
||||
assert.equal(Buffer.from(await photo.arrayBuffer()).toString(), 'telegram-image');
|
||||
});
|
||||
|
||||
test('Telegram bot client routes images through sendPhoto with the existing reply context', async () => {
|
||||
let request;
|
||||
const controller = new AbortController();
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendPhoto: async (value) => {
|
||||
request = value;
|
||||
return { message_id: 903 };
|
||||
},
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const file = {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('telegram-image'),
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.sendImage({
|
||||
chatId: -100456,
|
||||
replyToMessageId: 66,
|
||||
messageThreadId: 77,
|
||||
}, file), { message_id: 903 });
|
||||
assert.equal(request.chatId, -100456);
|
||||
assert.equal(request.file, file);
|
||||
assert.equal(request.replyToMessageId, 66);
|
||||
assert.equal(request.messageThreadId, 77);
|
||||
assert.equal(request.signal, controller.signal);
|
||||
});
|
||||
|
||||
test('Telegram photo delivery reuses stable artifact error mapping', async () => {
|
||||
const rejectedApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: 'Bad Request: unsupported photo',
|
||||
}, 400),
|
||||
});
|
||||
await assert.rejects(() => rejectedApi.sendPhoto({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.webp', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 400
|
||||
&& error.status === 400);
|
||||
|
||||
const uncertainApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => new Response('not-json', { status: 200 }),
|
||||
});
|
||||
await assert.rejects(() => uncertainApi.sendPhoto({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.png', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Telegram document errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
|
||||
|
|
@ -958,6 +1093,86 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
});
|
||||
|
||||
test('Telegram bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
|
||||
const scenarios = [{
|
||||
name: 'native image',
|
||||
suffix: 'native-image',
|
||||
fileName: 'result.png',
|
||||
content: Buffer.from([1, 2, 3]),
|
||||
expectedCalls: ['image:result.png:image/png'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'ordinary file',
|
||||
suffix: 'ordinary-file',
|
||||
fileName: 'result.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file:result.txt:text/plain'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'definitive image rejection',
|
||||
suffix: 'image-fallback',
|
||||
fileName: 'result.webp',
|
||||
content: Buffer.from([4, 5, 6]),
|
||||
imageErrorCode: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'uncertain image result',
|
||||
suffix: 'image-uncertain',
|
||||
fileName: 'result.gif',
|
||||
content: Buffer.from([7, 8, 9]),
|
||||
imageErrorCode: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image:result.gif:image/gif'],
|
||||
expectedOutcome: 'unknown',
|
||||
}];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const artifact = await committedTelegramArtifact(t, scenario);
|
||||
const calls = [];
|
||||
const bot = {
|
||||
sendText: async () => ({ message_id: `text-${scenario.suffix}` }),
|
||||
sendImage: async (_target, file) => {
|
||||
calls.push(`image:${file.fileName}:${file.mediaType}`);
|
||||
if (scenario.imageErrorCode) {
|
||||
const error = new Error(scenario.imageErrorCode);
|
||||
error.code = scenario.imageErrorCode;
|
||||
throw error;
|
||||
}
|
||||
return { message_id: `image-${scenario.suffix}` };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
calls.push(`file:${file.fileName}:${file.mediaType}`);
|
||||
return { message_id: `file-${scenario.suffix}` };
|
||||
},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot,
|
||||
state: memoryState(),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
createSession: async () => `session-${scenario.suffix}`,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: `telegram-artifact-${scenario.suffix}`,
|
||||
senderId: '42',
|
||||
kind: 'direct',
|
||||
conversationId: `telegram-artifact-${scenario.suffix}`,
|
||||
content: '生成结果',
|
||||
addressed: true,
|
||||
replyTarget: { chatId: 88, replyToMessageId: 7, messageThreadId: 9 },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
|
||||
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
sendWecomImage,
|
||||
WecomHarnessBridge,
|
||||
wecomInboundMessage,
|
||||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
|
|
@ -21,6 +22,190 @@ const PNG_1X1 = Buffer.from(
|
|||
'base64',
|
||||
);
|
||||
|
||||
test('Enterprise WeChat native image adapter uploads and sends an image media message', async () => {
|
||||
const calls = [];
|
||||
const result = { body: { msgid: 'wecom-image-1', errcode: 0 } };
|
||||
const file = {
|
||||
artifactId: 'image-artifact',
|
||||
deliveryKey: 'image-delivery',
|
||||
fileName: 'diagram.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: PNG_1X1,
|
||||
};
|
||||
const returned = await sendWecomImage({
|
||||
uploadMedia: async (bytes, options) => {
|
||||
calls.push({ operation: 'upload', bytes: Buffer.from(bytes), options });
|
||||
return { media_id: 'image-media-1' };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
calls.push({ operation: 'send', chatId, type, mediaId });
|
||||
return result;
|
||||
},
|
||||
}, 'chat-1', file);
|
||||
|
||||
assert.equal(returned, result);
|
||||
assert.deepEqual(calls, [{
|
||||
operation: 'upload',
|
||||
bytes: PNG_1X1,
|
||||
options: { type: 'image', filename: 'diagram.png' },
|
||||
}, {
|
||||
operation: 'send',
|
||||
chatId: 'chat-1',
|
||||
type: 'image',
|
||||
mediaId: 'image-media-1',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat native image adapter preserves definite upload errors', async () => {
|
||||
let sends = 0;
|
||||
await assert.rejects(sendWecomImage({
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('forbidden');
|
||||
error.httpStatus = 403;
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => { sends += 1; },
|
||||
}, 'chat-1', {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_1X1,
|
||||
}), (error) => error.code === 'artifact-permission-required');
|
||||
assert.equal(sends, 0);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat native image adapter marks an unacknowledged message as uncertain', async () => {
|
||||
await assert.rejects(sendWecomImage({
|
||||
uploadMedia: async () => ({ media_id: 'image-media-uncertain' }),
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
}, 'chat-1', {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_1X1,
|
||||
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat sends an image artifact as a native image message', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'native.png', PNG_1X1, 'native-image');
|
||||
const uploads = [];
|
||||
const messages = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, _content, finish) => (
|
||||
finish ? { body: { msgid: 'wecom-text' } } : undefined
|
||||
),
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async (bytes, options) => {
|
||||
uploads.push({ bytes: Buffer.from(bytes), options });
|
||||
return { media_id: 'native-image-media' };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
messages.push({ chatId, type, mediaId });
|
||||
return { body: { msgid: 'wecom-image', errcode: 0 } };
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'native-image-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-native-image' }));
|
||||
|
||||
assert.deepEqual(uploads, [{
|
||||
bytes: PNG_1X1,
|
||||
options: { type: 'image', filename: 'native.png' },
|
||||
}]);
|
||||
assert.deepEqual(messages, [{
|
||||
chatId: 'member-1',
|
||||
type: 'image',
|
||||
mediaId: 'native-image-media',
|
||||
}]);
|
||||
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
assert.equal(receipt.providerMessageIds.includes('wecom-image'), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat falls back to a file after a definite native-image rejection', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.png', PNG_1X1, 'image-fallback');
|
||||
const uploads = [];
|
||||
const messages = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.type);
|
||||
if (options.type === 'image') {
|
||||
const error = new Error('image format rejected');
|
||||
error.providerCode = 40014;
|
||||
throw error;
|
||||
}
|
||||
return { media_id: 'fallback-file-media' };
|
||||
},
|
||||
sendMediaMessage: async (_chatId, type, mediaId) => {
|
||||
messages.push({ type, mediaId });
|
||||
return { body: { msgid: 'wecom-file', errcode: 0 } };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-fallback' }));
|
||||
|
||||
assert.deepEqual(uploads, ['image', 'file']);
|
||||
assert.deepEqual(messages, [{ type: 'file', mediaId: 'fallback-file-media' }]);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat does not file-fallback after an uncertain native image send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.png', PNG_1X1, 'image-uncertain');
|
||||
const uploads = [];
|
||||
const notices = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
notices.push(body.markdown.content);
|
||||
return { body: { msgid: 'wecom-uncertain-notice' } };
|
||||
},
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.type);
|
||||
return { media_id: 'uncertain-image-media' };
|
||||
},
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-uncertain' }));
|
||||
|
||||
assert.deepEqual(uploads, ['image']);
|
||||
assert.match(notices[0], /发送结果未能确认.*不要立即重试/);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'unknown');
|
||||
});
|
||||
|
||||
function frame(overrides = {}) {
|
||||
return {
|
||||
headers: { req_id: 'req-1' },
|
||||
|
|
|
|||
|
|
@ -281,6 +281,76 @@ test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file i
|
|||
}]);
|
||||
});
|
||||
|
||||
test('sendImage uses the encrypted CDN flow and sends a native image item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=image-one',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-image-ticket' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
const plaintext = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
const result = await api.sendImage({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-image',
|
||||
file: {
|
||||
artifactId: 'artifact-image',
|
||||
deliveryKey: 'session:turn:artifact-image',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: plaintext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
const ticket = JSON.parse(calls[0].init.body);
|
||||
assert.equal(ticket.media_type, 1);
|
||||
assert.equal(ticket.to_user_id, 'wx-user');
|
||||
assert.equal(ticket.rawsize, plaintext.length);
|
||||
assert.equal(ticket.filesize, 16);
|
||||
assert.equal(ticket.no_need_thumb, true);
|
||||
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
|
||||
assert.equal(
|
||||
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
|
||||
true,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(calls[2].init.body).msg;
|
||||
assert.equal(sent.context_token, 'message-context');
|
||||
assert.equal(sent.run_id, 'run-image');
|
||||
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
|
||||
assert.equal(result.messageId, sent.client_id);
|
||||
assert.deepEqual(sent.item_list, [{
|
||||
type: 2,
|
||||
image_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-image-ticket',
|
||||
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
mid_size: 16,
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
function weixinFileRequest(overrides = {}) {
|
||||
return {
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
|
|
@ -351,6 +421,26 @@ test('sendFile marks every ambiguous sendmessage result as uncertain', async (t)
|
|||
}
|
||||
});
|
||||
|
||||
test('sendImage preserves the uncertain final-delivery boundary', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => { throw new TypeError('private socket detail'); }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.sendImage(weixinFileRequest({
|
||||
file: {
|
||||
artifactId: 'artifact-image-error',
|
||||
deliveryKey: 'session:turn:artifact-image-error',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('image-error-case'),
|
||||
},
|
||||
})),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
|
|
|
|||
|
|
@ -311,6 +311,104 @@ test('Weixin returns a registered result file with native context after its exis
|
|||
});
|
||||
});
|
||||
|
||||
test('Weixin routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_BYTES,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'weixin-image',
|
||||
expectedProviderIds: ['weixin-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'weixin-file',
|
||||
expectedProviderIds: ['weixin-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'weixin-file',
|
||||
expectedProviderIds: ['weixin-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', `session-image-route-${index}`);
|
||||
const calls = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async () => { throw new Error('text intentionally unavailable'); },
|
||||
sendImage: async ({ file }) => {
|
||||
calls.push('image');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return { messageId: 'weixin-native-image' };
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
calls.push('file');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
return { messageId: 'weixin-native-file' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`weixin-image-route-${index}`, '生成产物'));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ import {
|
|||
normalizeWhatsappAccessPolicy,
|
||||
} from '../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
import {
|
||||
WhatsappBotClient,
|
||||
WhatsappRuntime,
|
||||
createWhatsappMediaDownloader,
|
||||
normalizeWhatsappMessage,
|
||||
|
|
@ -687,6 +689,192 @@ test('WhatsApp runtime sends result files with native metadata, quote, stable id
|
|||
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
|
||||
});
|
||||
|
||||
test('WhatsApp bot client sends native images with stable id and early echo suppression', async () => {
|
||||
const remembered = [];
|
||||
const calls = [];
|
||||
const outboundIds = {
|
||||
remember: (id) => remembered.push(id),
|
||||
};
|
||||
const quoted = { key: { id: 'quoted-image-message' } };
|
||||
const deliveryKey = 'whatsapp-native-image-delivery';
|
||||
const expectedMessageId = createHash('sha256')
|
||||
.update(`${deliveryKey}:image`)
|
||||
.digest('hex')
|
||||
.slice(0, 20)
|
||||
.toUpperCase();
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async (jid, content, options) => {
|
||||
assert.equal(remembered.includes(options.messageId), true);
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: 'provider-image-message' } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, outboundIds);
|
||||
const bytes = Buffer.from('whatsapp-image');
|
||||
|
||||
assert.deepEqual(await client.sendImage({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
quoted,
|
||||
}, {
|
||||
deliveryKey,
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes,
|
||||
}), { key: { id: 'provider-image-message' } });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].jid, '16505550999@s.whatsapp.net');
|
||||
assert.equal(calls[0].content.image, bytes);
|
||||
assert.equal(calls[0].content.mimetype, 'image/png');
|
||||
assert.equal(calls[0].content.document, undefined);
|
||||
assert.equal(calls[0].options.quoted, quoted);
|
||||
assert.equal(calls[0].options.messageId, expectedMessageId);
|
||||
assert.equal(calls[0].options.mediaUploadTimeoutMs, 120_000);
|
||||
assert.deepEqual(remembered, [expectedMessageId, 'provider-image-message']);
|
||||
});
|
||||
|
||||
test('WhatsApp classifies a definite image rejection and uses a distinct fallback file id', async () => {
|
||||
const calls = [];
|
||||
const deliveryKey = 'whatsapp-image-fallback-delivery';
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async (_jid, content, options) => {
|
||||
calls.push({ content, options });
|
||||
if (content.image) {
|
||||
const error = new Error('unsupported image');
|
||||
error.output = { statusCode: 415 };
|
||||
throw error;
|
||||
}
|
||||
return { key: { id: 'provider-file-fallback' } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, { remember() {} });
|
||||
const file = {
|
||||
artifactId: 'whatsapp-image-fallback',
|
||||
deliveryKey,
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('whatsapp-image'),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => client.sendImage({ jid: '16505550999@s.whatsapp.net' }, file),
|
||||
(error) => error.code === 'artifact-provider-rejected',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await client.sendFile({ jid: '16505550999@s.whatsapp.net' }, file),
|
||||
{ key: { id: 'provider-file-fallback' } },
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].options.messageId, createHash('sha256')
|
||||
.update(`${deliveryKey}:image`).digest('hex').slice(0, 20).toUpperCase());
|
||||
assert.equal(calls[1].options.messageId, createHash('sha256')
|
||||
.update(deliveryKey).digest('hex').slice(0, 20).toUpperCase());
|
||||
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
|
||||
});
|
||||
|
||||
test('WhatsApp native image timeout remains an uncertain artifact delivery', async () => {
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async () => new Promise(() => {}),
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, { remember() {} }, {
|
||||
mediaUploadTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(() => client.sendImage({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
}, {
|
||||
artifactId: 'whatsapp-image-timeout',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('whatsapp-image'),
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
});
|
||||
|
||||
test('WhatsApp bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
|
||||
const scenarios = [{
|
||||
name: 'native image',
|
||||
suffix: 'native-image-route',
|
||||
fileName: 'result.png',
|
||||
content: Buffer.from([1, 2, 3]),
|
||||
expectedCalls: ['image:result.png:image/png'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'ordinary file',
|
||||
suffix: 'ordinary-file-route',
|
||||
fileName: 'result.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file:result.txt:text/plain'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'definitive image rejection',
|
||||
suffix: 'image-fallback-route',
|
||||
fileName: 'result.webp',
|
||||
content: Buffer.from([4, 5, 6]),
|
||||
imageErrorCode: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'uncertain image result',
|
||||
suffix: 'image-uncertain-route',
|
||||
fileName: 'result.gif',
|
||||
content: Buffer.from([7, 8, 9]),
|
||||
imageErrorCode: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image:result.gif:image/gif'],
|
||||
expectedOutcome: 'unknown',
|
||||
}];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { artifact } = await committedArtifact(t, scenario);
|
||||
const calls = [];
|
||||
const bot = {
|
||||
sendText: async () => ({ key: { id: `text-${scenario.suffix}` } }),
|
||||
sendImage: async (_target, file) => {
|
||||
calls.push(`image:${file.fileName}:${file.mediaType}`);
|
||||
if (scenario.imageErrorCode) {
|
||||
const error = new Error(scenario.imageErrorCode);
|
||||
error.code = scenario.imageErrorCode;
|
||||
throw error;
|
||||
}
|
||||
return { key: { id: `image-${scenario.suffix}` } };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
calls.push(`file:${file.fileName}:${file.mediaType}`);
|
||||
return { key: { id: `file-${scenario.suffix}` } };
|
||||
},
|
||||
};
|
||||
const bridge = new WhatsappHarnessBridge({
|
||||
bot,
|
||||
state: artifactState(`session-${scenario.suffix}`),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: `whatsapp:artifact-${scenario.suffix}`,
|
||||
senderId: '16505550999@s.whatsapp.net',
|
||||
kind: 'direct',
|
||||
conversationId: `whatsapp-artifact-${scenario.suffix}`,
|
||||
content: '生成结果',
|
||||
addressed: true,
|
||||
replyTarget: { jid: '16505550999@s.whatsapp.net' },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
|
||||
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('WhatsApp suppresses a self-chat file echo that arrives before the provider ACK', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'early-self-file-echo',
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ test('provider message ids are collected only from explicit message-id fields',
|
|||
assert.deepEqual(providerMessageIdsFor({ message_id: 42 }), ['42']);
|
||||
assert.deepEqual(providerMessageIdsFor({ key: { id: 'wamid-one' } }), ['wamid-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({ ts: '123.456' }), ['123.456']);
|
||||
assert.deepEqual(providerMessageIdsFor({ body: { msgid: 'wecom-one' } }), ['wecom-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({
|
||||
providerMessageIds: ['first', 'second', 'first', '', null],
|
||||
}), ['first', 'second']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue