mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 05:23:18 +08:00
feat: add native image delivery across channels
This commit is contained in:
parent
dbc0a4b730
commit
3ec5f1eca7
31 changed files with 2754 additions and 846 deletions
|
|
@ -540,6 +540,93 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
}
|
||||
|
||||
async function sendArtifact({
|
||||
clientId,
|
||||
clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal,
|
||||
}, { uploadType, messageKey, createMessageParams }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A DingTalk file is required');
|
||||
}
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
let token;
|
||||
try {
|
||||
token = await accessToken({ clientId, clientSecret, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
||||
uploadUrl.searchParams.set('access_token', token);
|
||||
uploadUrl.searchParams.set('type', uploadType);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(uploaded);
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (uploadRejection || !mediaId) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'upload-rejected',
|
||||
'钉钉服务拒绝了文件上传。',
|
||||
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
||||
));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const messageBody = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: messageKey,
|
||||
msgParam: JSON.stringify(createMessageParams(mediaId, file)),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body: messageBody,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '文件消息发送',
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyDingtalkFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async beginRegistration({ signal } = {}) {
|
||||
const initialized = assertRegistrationOk(await requestJson(
|
||||
|
|
@ -842,89 +929,24 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ clientId, clientSecret, target, file, signal }) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A DingTalk file is required');
|
||||
}
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
const fileType = dingtalkFileType(file.fileName);
|
||||
let token;
|
||||
try {
|
||||
token = await accessToken({ clientId, clientSecret, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
|
||||
uploadUrl.searchParams.set('access_token', token);
|
||||
uploadUrl.searchParams.set('type', 'file');
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
let uploaded;
|
||||
try {
|
||||
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw dingtalkArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(uploaded);
|
||||
if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'upload-rejected',
|
||||
'钉钉服务拒绝了文件上传。',
|
||||
{ providerCode: uploadRejection ?? 'missing-media-id' },
|
||||
));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const messageBody = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: 'sampleFile',
|
||||
msgParam: JSON.stringify({
|
||||
mediaId: uploaded.media_id,
|
||||
async sendFile(request) {
|
||||
return sendArtifact(request, {
|
||||
uploadType: 'file',
|
||||
messageKey: 'sampleFile',
|
||||
createMessageParams: (mediaId, file) => ({
|
||||
mediaId,
|
||||
fileName: file.fileName,
|
||||
fileType,
|
||||
fileType: dingtalkFileType(file.fileName),
|
||||
}),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body: messageBody,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '文件消息发送',
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyDingtalkFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw dingtalkArtifactError(new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
},
|
||||
|
||||
async sendImage(request) {
|
||||
return sendArtifact(request, {
|
||||
uploadType: 'image',
|
||||
messageKey: 'sampleImageMsg',
|
||||
createMessageParams: (mediaId) => ({ photoURL: mediaId }),
|
||||
});
|
||||
},
|
||||
|
||||
clearAccessToken(clientId) {
|
||||
|
|
|
|||
|
|
@ -35,14 +35,9 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -1084,70 +1079,39 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('DingTalk file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'dingtalk-file',
|
||||
providerMessageIds: dingtalkFileProviderIds(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const sendArtifact = async (method, file) => dingtalkFileProviderIds(
|
||||
await this.#api[method]({
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target,
|
||||
file,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
);
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
|
||||
channelKey: 'dingtalk',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#api.sendImage === 'function'
|
||||
? (file) => sendArtifact('sendImage', file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
sessionWebhook,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,14 +38,9 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
|
|
@ -1641,64 +1636,50 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#channel?.sendFile !== 'function') {
|
||||
const unavailable = new Error('Feishu file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(await this.#channel.sendFile(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeMessageId = null;
|
||||
try {
|
||||
noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
} catch {
|
||||
this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
if (receipts.length === 0) {
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
||||
channelKey: 'feishu',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#channel?.sendImage === 'function'
|
||||
? (file) => this.#channel.sendImage(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
})
|
||||
: undefined,
|
||||
sendFile: typeof this.#channel?.sendFile === 'function'
|
||||
? (file) => this.#channel.sendFile(chatId, file, {
|
||||
replyTo,
|
||||
signal: this.#signal,
|
||||
})
|
||||
: undefined,
|
||||
sendFailureNotice: async (artifact, error) => ({
|
||||
messageId: await this.#send(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
}),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
if (!delivery.receipt) {
|
||||
return {
|
||||
receipt: createDeliveryReceipt({
|
||||
deliveryId: replyTo,
|
||||
presentation: 'feishu-files',
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
const receipt = receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
|
||||
presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, failureNoticeVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, message) {
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
|||
});
|
||||
}
|
||||
|
||||
function deliveryUuid(file, chatId) {
|
||||
function deliveryUuid(file, chatId, messageType) {
|
||||
const seed = `${file.deliveryKey}\u0000${chatId}`;
|
||||
const digest = createHash('sha256')
|
||||
.update(`${file.deliveryKey}\u0000${chatId}`)
|
||||
.update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
|
||||
.digest('hex')
|
||||
.slice(0, 40);
|
||||
return `dshim_${digest}`;
|
||||
|
|
@ -230,6 +231,29 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
|
||||
async sendFile(chatId, file, { replyTo, signal } = {}) {
|
||||
return this.#sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType: 'file',
|
||||
presentation: 'feishu-file',
|
||||
});
|
||||
}
|
||||
|
||||
async sendImage(chatId, file, { replyTo, signal } = {}) {
|
||||
return this.#sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType: 'image',
|
||||
presentation: 'feishu-image',
|
||||
});
|
||||
}
|
||||
|
||||
async #sendArtifact(chatId, file, {
|
||||
replyTo,
|
||||
signal,
|
||||
messageType,
|
||||
presentation,
|
||||
}) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
|
||||
if (!file || typeof file !== 'object'
|
||||
|
|
@ -243,13 +267,20 @@ export class VerifiedFeishuChannel {
|
|||
try {
|
||||
uploaded = await waitForFileOperation((operationSignal) => {
|
||||
operationSignal.throwIfAborted();
|
||||
const pending = this.#client.im.v1.file.create({
|
||||
data: {
|
||||
file_type: 'stream',
|
||||
file_name: file.fileName,
|
||||
file: file.bytes,
|
||||
},
|
||||
});
|
||||
const pending = messageType === 'image'
|
||||
? this.#client.im.v1.image.create({
|
||||
data: {
|
||||
image_type: 'message',
|
||||
image: file.bytes,
|
||||
},
|
||||
})
|
||||
: this.#client.im.v1.file.create({
|
||||
data: {
|
||||
file_type: 'stream',
|
||||
file_name: file.fileName,
|
||||
file: file.bytes,
|
||||
},
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
}, {
|
||||
|
|
@ -262,21 +293,25 @@ export class VerifiedFeishuChannel {
|
|||
throw fileDeliveryError('upload', error);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = uploaded?.file_key;
|
||||
if (typeof fileKey !== 'string' || !fileKey) {
|
||||
const resourceKey = messageType === 'image'
|
||||
? uploaded?.image_key ?? uploaded?.data?.image_key
|
||||
: uploaded?.file_key ?? uploaded?.data?.file_key;
|
||||
if (typeof resourceKey !== 'string' || !resourceKey) {
|
||||
throw fileDeliveryError('upload', undefined, uploaded?.code);
|
||||
}
|
||||
|
||||
const uuid = deliveryUuid(file, chatId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
const uuid = deliveryUuid(file, chatId, messageType);
|
||||
const content = JSON.stringify(messageType === 'image'
|
||||
? { image_key: resourceKey }
|
||||
: { file_key: resourceKey });
|
||||
const request = replyTo
|
||||
? {
|
||||
path: { message_id: replyTo },
|
||||
data: { msg_type: 'file', content, uuid },
|
||||
data: { msg_type: messageType, content, uuid },
|
||||
}
|
||||
: {
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: { receive_id: chatId, msg_type: 'file', content, uuid },
|
||||
data: { receive_id: chatId, msg_type: messageType, content, uuid },
|
||||
};
|
||||
const send = () => {
|
||||
const pending = replyTo
|
||||
|
|
@ -300,7 +335,7 @@ export class VerifiedFeishuChannel {
|
|||
operationSignal.throwIfAborted();
|
||||
|
||||
// Feishu documents 230049 as an uncertain asynchronous send result.
|
||||
// Reuse the same file_key and UUID once so the provider can deduplicate.
|
||||
// Reuse the same resource key and UUID once so the provider can deduplicate.
|
||||
if (Number(result?.code) === 230049) {
|
||||
result = await send();
|
||||
operationSignal.throwIfAborted();
|
||||
|
|
@ -324,7 +359,7 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
presentation,
|
||||
providerMessageIds: [messageId],
|
||||
artifacts: [{
|
||||
artifactId: file.artifactId,
|
||||
|
|
|
|||
|
|
@ -32,14 +32,11 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import { sendMarkdownReply } from './markdown-reply.mjs';
|
||||
|
|
@ -210,7 +207,7 @@ function qqArtifactError(error, { dispatched = false } = {}) {
|
|||
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
|
||||
else if (status === 413) wrapped.code = 'artifact-too-large';
|
||||
else if (status === 429) wrapped.code = 'artifact-rate-limited';
|
||||
else if (status === 400 || status === 404) {
|
||||
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
|
||||
wrapped.code = 'artifact-provider-rejected';
|
||||
} else {
|
||||
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
|
||||
|
|
@ -245,6 +242,68 @@ function waitWithSignal(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
/** Send one materialized artifact through QQ's native image message. */
|
||||
export async function sendQqImage(
|
||||
bot,
|
||||
target,
|
||||
file,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof bot?.sendImage !== 'function') {
|
||||
const unavailable = new Error('QQ image delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const pending = bot.sendImage(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{ onProgress: () => signal?.throwIfAborted() },
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQqFile(
|
||||
bot,
|
||||
target,
|
||||
file,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof bot?.sendFile !== 'function') {
|
||||
const unavailable = new Error('QQ file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
const pending = bot.sendFile(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{
|
||||
fileName: file.fileName,
|
||||
onProgress: () => signal?.throwIfAborted(),
|
||||
},
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
}
|
||||
|
||||
function canClaimInteractionReply(message, pending) {
|
||||
return pending.questions[pending.index]
|
||||
&& nonEmptyString(message?.senderId) === pending.actor
|
||||
|
|
@ -491,80 +550,35 @@ export class QqHarnessBridge {
|
|||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('QQ file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
let result;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
const pending = this.#bot.sendFile(
|
||||
target,
|
||||
{ buffer: file.bytes },
|
||||
{
|
||||
fileName: file.fileName,
|
||||
onProgress: () => this.#signal?.throwIfAborted(),
|
||||
},
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw qqArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = nonEmptyString(result?.message?.id);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'qq-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (rawError) {
|
||||
if (this.#signal?.aborted) throw rawError;
|
||||
const error = qqArtifactError(rawError);
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
|
||||
failureNoticeVisible = true;
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
||||
receipts,
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
|
||||
alwaysMerge: true,
|
||||
channelKey: 'qq',
|
||||
signal: this.#signal,
|
||||
sendImage: (file) => sendQqImage(this.#bot, target, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
sendFile: (file) => sendQqFile(this.#bot, target, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal file
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './delivery.mjs';
|
||||
|
||||
function unavailableError() {
|
||||
const error = new Error('Native file delivery is unavailable');
|
||||
error.code = 'artifact-provider-unavailable';
|
||||
return error;
|
||||
}
|
||||
|
||||
function isAbort(error, signal) {
|
||||
return signal?.aborted
|
||||
|| error?.name === 'AbortError'
|
||||
|| error?.code === 'ABORT_ERR';
|
||||
}
|
||||
|
||||
function providerIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value
|
||||
.filter((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|| Number.isSafeInteger(candidate)
|
||||
))
|
||||
.map(String))];
|
||||
}
|
||||
return providerMessageIdsFor(value);
|
||||
}
|
||||
|
||||
async function sendMaterializedArtifact(file, {
|
||||
sendFile,
|
||||
sendImage,
|
||||
signal,
|
||||
}) {
|
||||
if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
|
||||
try {
|
||||
return {
|
||||
presentation: 'image',
|
||||
result: await sendImage(file),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof sendFile !== 'function') throw unavailableError();
|
||||
return {
|
||||
presentation: 'file',
|
||||
result: await sendFile(file),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver registered artifacts with one shared image-first policy while keeping
|
||||
* provider protocol details inside the channel-supplied send closures.
|
||||
*/
|
||||
export async function deliverOutboundArtifacts({
|
||||
artifacts = [],
|
||||
baseReceipt = null,
|
||||
deliveryId,
|
||||
aggregatePresentation,
|
||||
alwaysMerge = false,
|
||||
channelKey,
|
||||
signal,
|
||||
sendFile,
|
||||
sendImage,
|
||||
sendFailureNotice,
|
||||
logger,
|
||||
}) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
let failureNoticeVisible = false;
|
||||
let artifactsSent = 0;
|
||||
let artifactSendErrors = 0;
|
||||
|
||||
let artifactIndex = 0;
|
||||
try {
|
||||
while (artifactIndex < artifacts.length) {
|
||||
const artifact = artifacts[artifactIndex];
|
||||
artifactIndex += 1;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const file = await materializeOutboundArtifact(artifact, { signal });
|
||||
signal?.throwIfAborted();
|
||||
const sent = await sendMaterializedArtifact(file, {
|
||||
sendFile,
|
||||
sendImage,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${channelKey}-${sent.presentation}`,
|
||||
providerMessageIds: providerIds(sent.result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
artifactsSent += 1;
|
||||
userVisible = true;
|
||||
} catch (error) {
|
||||
if (isAbort(error, signal)) throw error;
|
||||
artifactSendErrors += 1;
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let messageIds = [];
|
||||
if (typeof sendFailureNotice === 'function') {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const notice = await sendFailureNotice(artifact, error);
|
||||
signal?.throwIfAborted();
|
||||
messageIds = providerIds(notice);
|
||||
failureNoticeVisible = true;
|
||||
} catch (noticeError) {
|
||||
if (isAbort(noticeError, signal)) throw noticeError;
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds: messageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
|
||||
userVisible = true;
|
||||
}
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
while (artifactIndex < artifacts.length) {
|
||||
releaseOutboundArtifact(artifacts[artifactIndex]);
|
||||
artifactIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let receipt = null;
|
||||
if (receipts.length === 1 && !alwaysMerge) {
|
||||
[receipt] = receipts;
|
||||
} else if (receipts.length > 0) {
|
||||
receipt = mergeDeliveryReceipts({
|
||||
deliveryId: deliveryId
|
||||
?? baseReceipt?.deliveryId
|
||||
?? artifacts[0]?.deliveryKey,
|
||||
presentation: aggregatePresentation
|
||||
?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
|
||||
receipts,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
receipt,
|
||||
userVisible,
|
||||
failureNoticeVisible,
|
||||
artifactsSent,
|
||||
artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
|
@ -576,7 +576,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
|
|||
};
|
||||
const definition = Object.freeze({
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -654,7 +654,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
|
|||
ctx.systemPrompt.section({
|
||||
name: 'dsh-im:return-file',
|
||||
order: 115,
|
||||
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export function providerMessageIdsFor(value) {
|
|||
value.message?.ts,
|
||||
value.key?.id,
|
||||
value.data?.message_id,
|
||||
value.body?.msgid,
|
||||
value.body?.message_id,
|
||||
];
|
||||
const id = candidates.find((candidate) => (
|
||||
(typeof candidate === 'string' && candidate.trim())
|
||||
|
|
|
|||
|
|
@ -32,14 +32,9 @@ import {
|
|||
harnessQuestionText,
|
||||
validHarnessQuestion,
|
||||
} from './harness-question.mjs';
|
||||
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from './semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -339,73 +334,29 @@ export class TextHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#bot.sendFile !== 'function') {
|
||||
const unavailable = new Error('Native file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const result = await this.#bot.sendFile(target, file);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: `${this.#descriptor.key}-file`,
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
let noticeSent = false;
|
||||
try {
|
||||
const notice = await this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
);
|
||||
providerMessageIds = providerMessageIdsFor(notice);
|
||||
noticeSent = true;
|
||||
} catch {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
|
||||
);
|
||||
}
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt
|
||||
? `${this.#descriptor.key}-text-and-files`
|
||||
: `${this.#descriptor.key}-files`,
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
channelKey: this.#descriptor.key,
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#bot.sendImage === 'function'
|
||||
? (file) => this.#bot.sendImage(target, file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#bot.sendFile === 'function'
|
||||
? (file) => this.#bot.sendFile(target, file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
|
|
|
|||
|
|
@ -35,33 +35,33 @@ function preserveProviderMetadata(target, source) {
|
|||
return target;
|
||||
}
|
||||
|
||||
function telegramArtifactProviderError(cause) {
|
||||
function telegramArtifactProviderError(cause, mediaLabel = 'document') {
|
||||
const providerCode = Number(cause?.providerCode);
|
||||
const status = Number(cause?.status);
|
||||
const message = cleanString(cause?.message) ?? '';
|
||||
let code = 'artifact-provider-rejected';
|
||||
let summary = 'Telegram rejected the document.';
|
||||
let summary = `Telegram rejected the ${mediaLabel}.`;
|
||||
if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
|
||||
code = 'artifact-permission-required';
|
||||
summary = 'Telegram denied permission to send the document.';
|
||||
summary = `Telegram denied permission to send the ${mediaLabel}.`;
|
||||
} else if (providerCode === 413 || status === 413
|
||||
|| /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
|
||||
code = 'artifact-too-large';
|
||||
summary = 'The document exceeds Telegram\'s size limit.';
|
||||
summary = `The ${mediaLabel} exceeds Telegram's size limit.`;
|
||||
} else if (providerCode === 429 || status === 429) {
|
||||
code = 'artifact-rate-limited';
|
||||
summary = 'Telegram rate-limited document delivery.';
|
||||
summary = `Telegram rate-limited ${mediaLabel} delivery.`;
|
||||
} else if (providerCode >= 500 || status >= 500) {
|
||||
code = 'artifact-delivery-uncertain';
|
||||
summary = 'Telegram document delivery result is uncertain.';
|
||||
summary = `Telegram ${mediaLabel} delivery result is uncertain.`;
|
||||
}
|
||||
const error = new Error(summary, { cause });
|
||||
error.code = code;
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
||||
function uncertainTelegramDelivery(cause) {
|
||||
const error = new Error('Telegram document delivery result is uncertain', { cause });
|
||||
function uncertainTelegramDelivery(cause, mediaLabel = 'document') {
|
||||
const error = new Error(`Telegram ${mediaLabel} delivery result is uncertain`, { cause });
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
return preserveProviderMetadata(error, cause);
|
||||
}
|
||||
|
|
@ -184,15 +184,41 @@ export class TelegramApi {
|
|||
}
|
||||
|
||||
async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#sendArtifact('sendDocument', 'document', 'document', {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPhoto({ chatId, file, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#sendArtifact('sendPhoto', 'photo', 'photo', {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async #sendArtifact(method, fieldName, mediaLabel, {
|
||||
chatId,
|
||||
file,
|
||||
replyToMessageId,
|
||||
messageThreadId,
|
||||
signal,
|
||||
}) {
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A Telegram document is required');
|
||||
throw new TypeError(`A Telegram ${mediaLabel} is required`);
|
||||
}
|
||||
const payload = new FormData();
|
||||
payload.append('chat_id', String(chatId));
|
||||
payload.append(
|
||||
'document',
|
||||
fieldName,
|
||||
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
|
||||
file.fileName,
|
||||
);
|
||||
|
|
@ -206,7 +232,7 @@ export class TelegramApi {
|
|||
if (signal?.aborted) throw abortReason(signal);
|
||||
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
|
||||
try {
|
||||
return await this.#call('sendDocument', payload, {
|
||||
return await this.#call(method, payload, {
|
||||
signal: uploadSignal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
multipart: true,
|
||||
|
|
@ -214,9 +240,9 @@ export class TelegramApi {
|
|||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
if (error?.code?.startsWith?.('telegram-')) {
|
||||
throw telegramArtifactProviderError(error);
|
||||
throw telegramArtifactProviderError(error, mediaLabel);
|
||||
}
|
||||
throw uncertainTelegramDelivery(error);
|
||||
throw uncertainTelegramDelivery(error, mediaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,16 @@ export class TelegramBotClient {
|
|||
});
|
||||
}
|
||||
|
||||
sendImage(target, file) {
|
||||
return this.#api.sendPhoto({
|
||||
chatId: target.chatId,
|
||||
file,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 4_000,
|
||||
|
|
|
|||
|
|
@ -31,15 +31,10 @@ import {
|
|||
inboundFileUserMessage,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
trackOutboundArtifactProviderPromise,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -353,6 +348,71 @@ function wecomArtifactError(error, { dispatched = false } = {}) {
|
|||
return wrapped;
|
||||
}
|
||||
|
||||
async function sendWecomMedia(
|
||||
client,
|
||||
chatId,
|
||||
file,
|
||||
mediaType,
|
||||
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof client?.uploadMedia !== 'function'
|
||||
|| typeof client?.sendMediaMessage !== 'function') {
|
||||
const unavailable = new Error(`Enterprise WeChat ${mediaType} delivery is unavailable`);
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
let uploaded;
|
||||
try {
|
||||
const pending = client.uploadMedia(file.bytes, {
|
||||
type: mediaType,
|
||||
filename: file.fileName,
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
uploaded = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw wecomArtifactError(error);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (!mediaId) {
|
||||
const rejected = new Error(`Enterprise WeChat ${mediaType} upload returned no media id`);
|
||||
rejected.code = 'artifact-provider-rejected';
|
||||
throw rejected;
|
||||
}
|
||||
|
||||
let sent;
|
||||
try {
|
||||
const pending = client.sendMediaMessage(chatId, mediaType, mediaId);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
sent = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw wecomArtifactError(error, { dispatched: true });
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
||||
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
throw wecomArtifactError({ providerCode });
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Send one materialized artifact through Enterprise WeChat's native image message. */
|
||||
export function sendWecomImage(client, chatId, file, options) {
|
||||
return sendWecomMedia(client, chatId, file, 'image', options);
|
||||
}
|
||||
|
||||
function sendWecomFile(client, chatId, file, options) {
|
||||
return sendWecomMedia(client, chatId, file, 'file', options);
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : '任务已完成,但没有生成可显示的文本。';
|
||||
|
|
@ -650,98 +710,35 @@ export class WecomHarnessBridge {
|
|||
if (artifacts.length === 0) {
|
||||
return { receipt: baseReceipt, failureNoticeVisible: false };
|
||||
}
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let failureNoticeVisible = false;
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#client.uploadMedia !== 'function'
|
||||
|| typeof this.#client.sendMediaMessage !== 'function') {
|
||||
const unavailable = new Error('Enterprise WeChat file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
this.#signal?.throwIfAborted();
|
||||
const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
|
||||
const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
|
||||
let uploaded;
|
||||
try {
|
||||
const pending = this.#client.uploadMedia(file.bytes, {
|
||||
type: 'file',
|
||||
filename: file.fileName,
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
uploaded = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const mediaId = nonEmptyString(uploaded?.media_id);
|
||||
if (!mediaId) {
|
||||
const rejected = new Error('Enterprise WeChat upload returned no media id');
|
||||
rejected.code = 'artifact-provider-rejected';
|
||||
throw rejected;
|
||||
}
|
||||
let sent;
|
||||
try {
|
||||
const pending = this.#client.sendMediaMessage(chatId, 'file', mediaId);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
sent = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw wecomArtifactError(error, { dispatched: true });
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
const providerCode = Number(sent?.body?.errcode ?? sent?.errcode);
|
||||
if (Number.isFinite(providerCode) && providerCode !== 0) {
|
||||
throw wecomArtifactError({ providerCode });
|
||||
}
|
||||
const messageId = providerMessageId(sent);
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'wecom-file',
|
||||
providerMessageIds: messageId ? [messageId] : [],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:wecom] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let providerMessageIds = [];
|
||||
try {
|
||||
providerMessageIds = await this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
);
|
||||
failureNoticeVisible = true;
|
||||
} catch (noticeError) {
|
||||
if (this.#signal?.aborted) throw noticeError;
|
||||
this.#logger.warn?.('[dsh-im:wecom] unable to send the safe result-file failure notice');
|
||||
}
|
||||
receipts.push(createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
}));
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
return {
|
||||
receipt: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
||||
receipts,
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'wecom-text-and-files' : 'wecom-files',
|
||||
alwaysMerge: true,
|
||||
channelKey: 'wecom',
|
||||
signal: this.#signal,
|
||||
sendImage: (file) => sendWecomImage(this.#client, chatId, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
failureNoticeVisible,
|
||||
sendFile: (file) => sendWecomFile(this.#client, chatId, file, {
|
||||
signal: this.#signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
}),
|
||||
sendFailureNotice: (artifact, error) => this.#sendActive(
|
||||
chatId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
failureNoticeVisible: delivery.failureNoticeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -457,6 +457,123 @@ function validateLoginResponse(value) {
|
|||
export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||
|
||||
async function sendArtifact({
|
||||
baseUrl,
|
||||
token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal,
|
||||
}, { mediaType, createItem }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient || !file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('toUserId and a file are required');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = randomBytes(16).toString('hex');
|
||||
const aesKey = randomBytes(16);
|
||||
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
||||
let upload;
|
||||
try {
|
||||
upload = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getuploadurl',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
filekey: fileKey,
|
||||
media_type: mediaType,
|
||||
to_user_id: recipient,
|
||||
rawsize: file.bytes.byteLength,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(upload);
|
||||
if (uploadRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'upload-url-rejected',
|
||||
'微信服务拒绝了文件上传请求。',
|
||||
{ providerCode: uploadRejection },
|
||||
));
|
||||
}
|
||||
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
||||
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
||||
let downloadParam;
|
||||
try {
|
||||
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
||||
?? randomUUID();
|
||||
const clientIdSeed = mediaType === 3 ? deliverySeed : `${deliverySeed}\u0000${mediaType}`;
|
||||
const clientId = `dsh-weixin-${createHash('sha256')
|
||||
.update(clientIdSeed)
|
||||
.digest('hex')
|
||||
.slice(0, 32)}`;
|
||||
const media = {
|
||||
encrypt_query_param: downloadParam,
|
||||
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
};
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
||||
},
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyWeixinFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'send-rejected',
|
||||
'微信服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
inboundImages(message) {
|
||||
return extractWeixinImages(message, { fetchImpl });
|
||||
|
|
@ -549,115 +666,31 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient || !file || typeof file !== 'object'
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('toUserId and a file are required');
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = randomBytes(16).toString('hex');
|
||||
const aesKey = randomBytes(16);
|
||||
const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
|
||||
let upload;
|
||||
try {
|
||||
upload = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getuploadurl',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
filekey: fileKey,
|
||||
media_type: 3,
|
||||
to_user_id: recipient,
|
||||
rawsize: file.bytes.byteLength,
|
||||
rawfilemd5: rawMd5,
|
||||
filesize: aesEcbPaddedSize(file.bytes.byteLength),
|
||||
no_need_thumb: true,
|
||||
aeskey: aesKey.toString('hex'),
|
||||
base_info: baseInfo(),
|
||||
async sendFile(request) {
|
||||
return sendArtifact(request, {
|
||||
mediaType: 3,
|
||||
createItem: ({ file, media }) => ({
|
||||
type: 4,
|
||||
file_item: {
|
||||
media,
|
||||
file_name: file.fileName,
|
||||
len: String(file.bytes.byteLength),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
const uploadRejection = rejectedProviderResponse(upload);
|
||||
if (uploadRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'upload-url-rejected',
|
||||
'微信服务拒绝了文件上传请求。',
|
||||
{ providerCode: uploadRejection },
|
||||
));
|
||||
}
|
||||
const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
|
||||
const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
|
||||
let downloadParam;
|
||||
try {
|
||||
downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
const status = Number(error?.status);
|
||||
const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
|
||||
? 'artifact-provider-rejected'
|
||||
: 'artifact-provider-failed';
|
||||
throw weixinArtifactError(error, { fallback });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
|
||||
?? randomUUID();
|
||||
const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendmessage',
|
||||
token,
|
||||
signal,
|
||||
body: {
|
||||
msg: {
|
||||
from_user_id: '',
|
||||
to_user_id: recipient,
|
||||
client_id: clientId,
|
||||
message_type: 2,
|
||||
message_state: 2,
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: downloadParam,
|
||||
aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: file.fileName,
|
||||
len: String(file.bytes.byteLength),
|
||||
},
|
||||
}],
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
|
||||
},
|
||||
base_info: baseInfo(),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async sendImage(request) {
|
||||
return sendArtifact(request, {
|
||||
mediaType: 1,
|
||||
createItem: ({ media, ciphertextSize }) => ({
|
||||
type: 2,
|
||||
image_item: {
|
||||
media,
|
||||
mid_size: ciphertextSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw classifyWeixinFinalDeliveryError(error, signal);
|
||||
}
|
||||
const sendRejection = rejectedProviderResponse(response);
|
||||
if (sendRejection) {
|
||||
throw weixinArtifactError(new WeixinApiError(
|
||||
'send-rejected',
|
||||
'微信服务拒绝了文件消息。',
|
||||
{ providerCode: sendRejection },
|
||||
));
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async notifyStart({ baseUrl, token, signal }) {
|
||||
|
|
|
|||
|
|
@ -38,14 +38,9 @@ import {
|
|||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
providerMessageIdsFor,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
|
||||
|
|
@ -869,73 +864,40 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
let userVisible = Boolean(baseReceipt);
|
||||
for (const artifact of artifacts) {
|
||||
this.#signal?.throwIfAborted();
|
||||
try {
|
||||
if (typeof this.#api.sendFile !== 'function') {
|
||||
const unavailable = new Error('Weixin file delivery is unavailable');
|
||||
unavailable.code = 'artifact-provider-unavailable';
|
||||
throw unavailable;
|
||||
}
|
||||
const file = await materializeOutboundArtifact(artifact, {
|
||||
signal: this.#signal,
|
||||
});
|
||||
const result = await this.#api.sendFile({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
receipts.push(createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'weixin-file',
|
||||
providerMessageIds: providerMessageIdsFor(result),
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
}));
|
||||
userVisible = true;
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw error;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let noticeSent = false;
|
||||
const providerMessageIds = await this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
contextToken,
|
||||
runId,
|
||||
).then((ids) => {
|
||||
noticeSent = true;
|
||||
return ids;
|
||||
}).catch(() => []);
|
||||
const failureReceipt = createArtifactFailureReceipt({
|
||||
artifactId: artifact?.artifactId ?? 'unknown',
|
||||
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
|
||||
error,
|
||||
providerMessageIds,
|
||||
});
|
||||
receipts.push(failureReceipt);
|
||||
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
|
||||
} finally {
|
||||
releaseOutboundArtifact(artifact);
|
||||
}
|
||||
}
|
||||
const receipt = receipts.length === 0
|
||||
? null
|
||||
: receipts.length === 1
|
||||
? receipts[0]
|
||||
: mergeDeliveryReceipts({
|
||||
deliveryId: replyTo,
|
||||
presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
||||
receipts,
|
||||
});
|
||||
return { receipt, userVisible };
|
||||
const sendArtifact = (method, file) => this.#api[method]({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
file,
|
||||
contextToken,
|
||||
runId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
baseReceipt,
|
||||
deliveryId: replyTo,
|
||||
aggregatePresentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
|
||||
channelKey: 'weixin',
|
||||
signal: this.#signal,
|
||||
sendImage: typeof this.#api.sendImage === 'function'
|
||||
? (file) => sendArtifact('sendImage', file)
|
||||
: undefined,
|
||||
sendFile: typeof this.#api.sendFile === 'function'
|
||||
? (file) => sendArtifact('sendFile', file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#send(
|
||||
toUserId,
|
||||
artifactFailureText(artifact?.fileName, error),
|
||||
contextToken,
|
||||
runId,
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
|
||||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,12 +313,31 @@ function waitWithSignal(promise, signal) {
|
|||
|
||||
function uncertainArtifactDelivery(error) {
|
||||
if (error?.code === 'artifact-delivery-uncertain') return error;
|
||||
const uncertain = new Error('WhatsApp could not confirm result file delivery.');
|
||||
const uncertain = new Error('WhatsApp could not confirm artifact delivery.');
|
||||
uncertain.code = 'artifact-delivery-uncertain';
|
||||
uncertain.cause = error;
|
||||
return uncertain;
|
||||
}
|
||||
|
||||
function whatsappArtifactError(error) {
|
||||
if (error?.code?.startsWith?.('artifact-')) return error;
|
||||
const status = error?.output?.statusCode
|
||||
?? error?.data?.statusCode
|
||||
?? error?.statusCode;
|
||||
let code;
|
||||
if (status === 401 || status === 403) code = 'artifact-permission-required';
|
||||
else if (status === 413) code = 'artifact-too-large';
|
||||
else if (status === 429) code = 'artifact-rate-limited';
|
||||
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
|
||||
code = 'artifact-provider-rejected';
|
||||
}
|
||||
if (!code) return uncertainArtifactDelivery(error);
|
||||
const wrapped = new Error('WhatsApp rejected artifact delivery.');
|
||||
wrapped.code = code;
|
||||
wrapped.cause = error;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export class WhatsappBotClient {
|
||||
#socket;
|
||||
#outboundIds;
|
||||
|
|
@ -354,14 +373,32 @@ export class WhatsappBotClient {
|
|||
}
|
||||
|
||||
async sendFile(target, file) {
|
||||
return this.#sendArtifact(target, file, {
|
||||
document: file.bytes,
|
||||
mimetype: file.mediaType ?? 'application/octet-stream',
|
||||
fileName: file.fileName,
|
||||
}, 'file');
|
||||
}
|
||||
|
||||
async sendImage(target, file) {
|
||||
return this.#sendArtifact(target, file, {
|
||||
image: file.bytes,
|
||||
mimetype: file.mediaType ?? 'image/jpeg',
|
||||
}, 'image');
|
||||
}
|
||||
|
||||
async #sendArtifact(target, file, content, presentation) {
|
||||
this.#signal?.throwIfAborted();
|
||||
await this.#stopTyping(target.jid);
|
||||
this.#signal?.throwIfAborted();
|
||||
const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
|
||||
? file.deliveryKey
|
||||
: file.artifactId;
|
||||
const messageId = typeof deliverySeed === 'string' && deliverySeed
|
||||
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 20).toUpperCase()
|
||||
const messageIdSeed = presentation === 'image'
|
||||
? `${deliverySeed}:image`
|
||||
: deliverySeed;
|
||||
const messageId = typeof messageIdSeed === 'string' && messageIdSeed
|
||||
? createHash('sha256').update(messageIdSeed).digest('hex').slice(0, 20).toUpperCase()
|
||||
: undefined;
|
||||
const options = {
|
||||
...(target.quoted ? { quoted: target.quoted } : {}),
|
||||
|
|
@ -375,11 +412,7 @@ export class WhatsappBotClient {
|
|||
try {
|
||||
const pending = this.#socket.sendMessage(
|
||||
target.jid,
|
||||
{
|
||||
document: file.bytes,
|
||||
mimetype: file.mediaType ?? 'application/octet-stream',
|
||||
fileName: file.fileName,
|
||||
},
|
||||
content,
|
||||
options,
|
||||
);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
|
|
@ -390,7 +423,7 @@ export class WhatsappBotClient {
|
|||
result = await waitWithSignal(pending, waitSignal);
|
||||
} catch (error) {
|
||||
if (this.#signal?.aborted) throw abortReason(this.#signal);
|
||||
throw uncertainArtifactDelivery(error);
|
||||
throw whatsappArtifactError(error);
|
||||
}
|
||||
this.#signal?.throwIfAborted();
|
||||
this.#outboundIds.remember(result?.key?.id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue