feat: add native image delivery across channels

This commit is contained in:
xmanrui 2026-08-24 03:25:38 +08:00
parent dbc0a4b730
commit 3ec5f1eca7
31 changed files with 2754 additions and 846 deletions

View file

@ -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 都能在原生客户端直接看到图片或图片附件预览。

File diff suppressed because one or more lines are too long

View file

@ -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) {

View file

@ -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 };
}
}

View file

@ -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) {

View file

@ -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,

View file

@ -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,
};
}

View 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,
};
}

View file

@ -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;
}

View file

@ -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())

View file

@ -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, {

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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 }) {

View file

@ -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 };
}
}

View file

@ -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);

View 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);
});

View 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' },

View file

@ -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();

View file

@ -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 = [];

View file

@ -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');

View file

@ -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) => {

View file

@ -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 = [];

View file

@ -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,

View file

@ -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();

View file

@ -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' },

View file

@ -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' },

View file

@ -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();

View file

@ -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',

View file

@ -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']);