mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -34,6 +34,15 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
releaseOutboundArtifact,
|
||||
} from '../shared/semantic/artifact.mjs';
|
||||
import {
|
||||
createArtifactFailureReceipt,
|
||||
createDeliveryReceipt,
|
||||
mergeDeliveryReceipts,
|
||||
} from '../shared/semantic/delivery.mjs';
|
||||
import {
|
||||
MENU_PAGE_SIZE,
|
||||
completionCard,
|
||||
|
|
@ -126,6 +135,33 @@ function safeErrorText(error) {
|
|||
}
|
||||
}
|
||||
|
||||
function artifactFailureText(fileName, error) {
|
||||
const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
|
||||
switch (error?.code) {
|
||||
case 'artifact-permission-required':
|
||||
return `结果文件「${name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。`;
|
||||
case 'artifact-too-large':
|
||||
return `结果文件「${name}」超过飞书 30 MB 上限,未发送。`;
|
||||
case 'artifact-empty':
|
||||
return `结果文件「${name}」为空,飞书不允许发送空文件。`;
|
||||
case 'artifact-changed':
|
||||
case 'artifact-invalid':
|
||||
case 'artifact-unavailable':
|
||||
return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
|
||||
case 'artifact-rate-limited':
|
||||
return `结果文件「${name}」暂时被飞书限流,未能发送,请稍后重试。`;
|
||||
case 'artifact-delivery-uncertain':
|
||||
return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
|
||||
default:
|
||||
return `结果文件「${name}」已生成,但暂时未能发送,请稍后重试。`;
|
||||
}
|
||||
}
|
||||
|
||||
function answerTextForDelivery(answer, artifacts) {
|
||||
if (typeof answer === 'string' && answer.trim()) return answer;
|
||||
return artifacts.length > 0 ? '结果文件已生成。' : answer;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
|
@ -540,7 +576,10 @@ export class FeishuHarnessBridge {
|
|||
.then(() => this.#handle(event, key, { alreadyRecorded }));
|
||||
const settled = finalize
|
||||
? work
|
||||
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
||||
.then(async (receipt) => {
|
||||
await this.#finishReaction(messageId, processingReaction, 'DONE');
|
||||
return receipt;
|
||||
})
|
||||
.catch((error) => this.#handleMessageFailure(
|
||||
event,
|
||||
messageId,
|
||||
|
|
@ -736,10 +775,11 @@ export class FeishuHarnessBridge {
|
|||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
try {
|
||||
await this.#answerWithStream(event, key, message);
|
||||
const receipt = await this.#answerWithStream(event, key, message);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
return receipt;
|
||||
} finally {
|
||||
await this.#cancelPendingInteraction(key);
|
||||
await this.#approvals.closeRoute(key);
|
||||
|
|
@ -1578,6 +1618,81 @@ export class FeishuHarnessBridge {
|
|||
};
|
||||
}
|
||||
|
||||
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitText(answer)) {
|
||||
this.#signal?.throwIfAborted();
|
||||
const messageId = await this.#send(chatId, chunk);
|
||||
if (messageId) providerMessageIds.push(messageId);
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId,
|
||||
presentation,
|
||||
providerMessageIds,
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
receipt: createDeliveryReceipt({
|
||||
deliveryId: replyTo,
|
||||
presentation: 'feishu-files',
|
||||
}),
|
||||
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 };
|
||||
}
|
||||
|
||||
async #answerWithStream(event, key, message) {
|
||||
const chatId = event.message.chat_id;
|
||||
const messageId = event.message.message_id;
|
||||
|
|
@ -1586,7 +1701,7 @@ export class FeishuHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1596,15 +1711,41 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
});
|
||||
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(answer, artifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
textSendError = error;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] final text delivery failed; continuing with result files:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return;
|
||||
return delivery.receipt;
|
||||
}
|
||||
|
||||
let promptStarted = false;
|
||||
let completedAnswer = '';
|
||||
let completedArtifacts = [];
|
||||
let stream;
|
||||
try {
|
||||
await this.#channel.stream(chatId, {
|
||||
stream = await this.#channel.stream(chatId, {
|
||||
markdown: async (controller) => {
|
||||
promptStarted = true;
|
||||
const askOptions = {
|
||||
|
|
@ -1614,7 +1755,7 @@ export class FeishuHarnessBridge {
|
|||
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
||||
},
|
||||
};
|
||||
({ answer: completedAnswer } = await askInWorkspaceSession({
|
||||
const completed = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1623,26 +1764,56 @@ export class FeishuHarnessBridge {
|
|||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
}));
|
||||
await controller.setContent(completedAnswer);
|
||||
});
|
||||
completedAnswer = completed.answer;
|
||||
completedArtifacts = completed.artifacts ?? [];
|
||||
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
||||
},
|
||||
}, { replyTo: messageId });
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
} catch (error) {
|
||||
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
||||
if (completedAnswer) {
|
||||
if (completedAnswer || completedArtifacts.length > 0) {
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] native stream failed after generation; sending final text:',
|
||||
error.message,
|
||||
);
|
||||
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(completedAnswer, completedArtifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text-fallback',
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
chatId,
|
||||
messageId,
|
||||
completedArtifacts,
|
||||
textReceipt,
|
||||
);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return;
|
||||
return delivery.receipt;
|
||||
}
|
||||
if (promptStarted) throw error;
|
||||
|
||||
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
|
|
@ -1652,9 +1823,46 @@ export class FeishuHarnessBridge {
|
|||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key),
|
||||
});
|
||||
for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
|
||||
let textReceipt;
|
||||
let textSendError = null;
|
||||
try {
|
||||
textReceipt = await this.#sendAnswerText(
|
||||
chatId,
|
||||
answerTextForDelivery(answer, artifacts),
|
||||
{
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-text-fallback',
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
textSendError = fallbackError;
|
||||
this.#logger.warn?.(
|
||||
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
|
||||
const artifactDispatched = delivery.receipt.artifacts.some(
|
||||
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
|
||||
);
|
||||
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
|
||||
throw textSendError;
|
||||
}
|
||||
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
}
|
||||
const delivery = await this.#deliverArtifacts(
|
||||
chatId,
|
||||
messageId,
|
||||
completedArtifacts,
|
||||
createDeliveryReceipt({
|
||||
deliveryId: messageId,
|
||||
presentation: 'feishu-cardkit',
|
||||
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
|
||||
}),
|
||||
);
|
||||
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
|
||||
return delivery.receipt;
|
||||
}
|
||||
|
||||
async #processInteractionReply(event, messageId, key, expected, processingReaction) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
||||
import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
|
||||
|
||||
const STREAM_ELEMENT_ID = 'stream_md';
|
||||
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
|
||||
const MAX_STREAM_CHARS = 28000;
|
||||
const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
|
||||
|
||||
const FILE_DELIVERY_ERRORS = new Map([
|
||||
[99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
|
||||
[234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
|
||||
[234010, ['artifact-empty', 'Feishu does not accept empty files.']],
|
||||
[230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
|
||||
[230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
|
||||
[230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
|
||||
[230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
|
||||
]);
|
||||
|
||||
function assertApiSuccess(operation, response) {
|
||||
if (response?.code && response.code !== 0) {
|
||||
|
|
@ -9,6 +25,103 @@ function assertApiSuccess(operation, response) {
|
|||
return response;
|
||||
}
|
||||
|
||||
function providerErrorCode(cause) {
|
||||
const pending = [cause];
|
||||
const seen = new Set();
|
||||
let fallback;
|
||||
while (pending.length > 0) {
|
||||
const value = pending.shift();
|
||||
if (!value || seen.has(value)) continue;
|
||||
if (typeof value === 'object') seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
pending.push(...value);
|
||||
continue;
|
||||
}
|
||||
const code = Number(value?.code);
|
||||
if (Number.isFinite(code) && code !== 0) {
|
||||
if (FILE_DELIVERY_ERRORS.has(code)) return code;
|
||||
fallback ??= code;
|
||||
}
|
||||
pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
|
||||
const explicitCode = providerCode === undefined || providerCode === null
|
||||
? undefined
|
||||
: Number(providerCode);
|
||||
const code = Number.isFinite(explicitCode) && explicitCode !== 0
|
||||
? explicitCode
|
||||
: providerErrorCode(cause);
|
||||
const fallback = Number.isFinite(code)
|
||||
? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
|
||||
: uncertain
|
||||
? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
|
||||
: ['artifact-provider-failed', `Feishu file ${stage} failed.`];
|
||||
const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
|
||||
const error = new Error(message, { cause });
|
||||
error.code = errorCode;
|
||||
if (Number.isFinite(code)) error.providerCode = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function boundedFileTimeout(value, name) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
|
||||
throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function operationTimeout(stage) {
|
||||
const error = new Error(`Feishu file ${stage} timed out.`);
|
||||
error.code = 'provider-timeout';
|
||||
return error;
|
||||
}
|
||||
|
||||
function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = new AbortController();
|
||||
const operationSignal = signal
|
||||
? AbortSignal.any([signal, deadline.signal])
|
||||
: deadline.signal;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
operationSignal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(
|
||||
reject,
|
||||
signal?.aborted ? abortReason(signal) : operationTimeout(stage),
|
||||
);
|
||||
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
||||
operationSignal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
Promise.resolve().then(() => operation(operationSignal)).then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
if (operationSignal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
function deliveryUuid(file, chatId) {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${file.deliveryKey}\u0000${chatId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 40);
|
||||
return `dshim_${digest}`;
|
||||
}
|
||||
|
||||
function summaryOf(text) {
|
||||
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
|
||||
|
|
@ -39,10 +152,19 @@ function streamingCard(initialText) {
|
|||
export class VerifiedFeishuChannel {
|
||||
#client;
|
||||
#initialText;
|
||||
#fileUploadTimeoutMs;
|
||||
#fileMessageTimeoutMs;
|
||||
|
||||
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
|
||||
constructor({
|
||||
client,
|
||||
initialText = DEFAULT_INITIAL_TEXT,
|
||||
fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
||||
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
|
||||
}) {
|
||||
this.#client = client;
|
||||
this.#initialText = initialText;
|
||||
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
|
||||
}
|
||||
|
||||
async stream(chatId, input, options = {}) {
|
||||
|
|
@ -107,6 +229,110 @@ export class VerifiedFeishuChannel {
|
|||
}
|
||||
}
|
||||
|
||||
async sendFile(chatId, file, { replyTo, signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
|
||||
if (!file || typeof file !== 'object'
|
||||
|| typeof file.artifactId !== 'string' || !file.artifactId
|
||||
|| typeof file.deliveryKey !== 'string' || !file.deliveryKey
|
||||
|| typeof file.fileName !== 'string' || !file.fileName
|
||||
|| !Buffer.isBuffer(file.bytes)) {
|
||||
throw new TypeError('A materialized result file is required');
|
||||
}
|
||||
let uploaded;
|
||||
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,
|
||||
},
|
||||
});
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
}, {
|
||||
signal,
|
||||
timeoutMs: this.#fileUploadTimeoutMs,
|
||||
stage: 'upload',
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw fileDeliveryError('upload', error);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const fileKey = uploaded?.file_key;
|
||||
if (typeof fileKey !== 'string' || !fileKey) {
|
||||
throw fileDeliveryError('upload', undefined, uploaded?.code);
|
||||
}
|
||||
|
||||
const uuid = deliveryUuid(file, chatId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
const request = replyTo
|
||||
? {
|
||||
path: { message_id: replyTo },
|
||||
data: { msg_type: 'file', content, uuid },
|
||||
}
|
||||
: {
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: { receive_id: chatId, msg_type: 'file', content, uuid },
|
||||
};
|
||||
const send = () => {
|
||||
const pending = replyTo
|
||||
? this.#client.im.v1.message.reply(request)
|
||||
: this.#client.im.v1.message.create(request);
|
||||
trackOutboundArtifactProviderPromise(file, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await waitForFileOperation(async (operationSignal) => {
|
||||
operationSignal.throwIfAborted();
|
||||
let result;
|
||||
try {
|
||||
result = await send();
|
||||
} catch (error) {
|
||||
if (providerErrorCode(error) !== 230049) throw error;
|
||||
result = { code: 230049 };
|
||||
}
|
||||
operationSignal.throwIfAborted();
|
||||
|
||||
// Feishu documents 230049 as an uncertain asynchronous send result.
|
||||
// Reuse the same file_key and UUID once so the provider can deduplicate.
|
||||
if (Number(result?.code) === 230049) {
|
||||
result = await send();
|
||||
operationSignal.throwIfAborted();
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
signal,
|
||||
timeoutMs: this.#fileMessageTimeoutMs,
|
||||
stage: 'message send',
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
throw fileDeliveryError('message send', error, undefined, { uncertain: true });
|
||||
}
|
||||
if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
|
||||
throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
|
||||
}
|
||||
const messageId = response?.data?.message_id;
|
||||
if (typeof messageId !== 'string' || !messageId) {
|
||||
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
||||
}
|
||||
return createDeliveryReceipt({
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: [messageId],
|
||||
artifacts: [{
|
||||
artifactId: file.artifactId,
|
||||
outcome: 'sent',
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
async #sendCard(chatId, cardId, replyTo) {
|
||||
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
||||
const response = replyTo
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
|||
'im:message:send_as_bot',
|
||||
'im:message.reactions:write_only',
|
||||
'im:message:recall',
|
||||
'im:resource',
|
||||
'cardkit:card:write',
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue