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

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