mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue