import { createHash } from 'node:crypto'; import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs'; import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs'; import { t } from '../shared/i18n.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) { throw new Error(`${operation} failed: ${response.msg || response.code}`); } 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, messageType) { const seed = `${file.deliveryKey}\u0000${chatId}`; const digest = createHash('sha256') .update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`) .digest('hex') .slice(0, 40); return `dshim_${digest}`; } function summaryOf(text) { const summary = String(text ?? '').replace(/\s+/g, ' ').trim(); return summary.length <= 50 ? summary : `${streamTextPrefix(summary, 49)}…`; } function streamTextPrefix(text, maxChars) { let end = Math.min(text.length, maxChars); // Keep a UTF-16 surrogate pair together when the limit lands inside an emoji. const before = text.charCodeAt(end - 1); const after = text.charCodeAt(end); if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1; return text.slice(0, end); } function streamPreview(text) { if (text.length <= MAX_STREAM_CHARS) return text; const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`; return streamTextPrefix(text, MAX_STREAM_CHARS - notice.length) + notice; } function splitStreamContent(text) { const chunks = []; let remaining = text; while (remaining.length > MAX_STREAM_CHARS) { const prefix = streamTextPrefix(remaining, MAX_STREAM_CHARS); let end = prefix.lastIndexOf('\n') + 1; if (end < MAX_STREAM_CHARS * 0.6) end = prefix.length; chunks.push(remaining.slice(0, end)); // Unlike plain-text fallback splitting, keep all whitespace in the answer. remaining = remaining.slice(end); } if (remaining) chunks.push(remaining); return chunks; } function streamingCard(initialText) { return { schema: '2.0', config: { streaming_mode: true, summary: { content: t('正在生成…') }, streaming_config: { print_frequency_ms: { default: 70 }, print_step: { default: 1 }, print_strategy: 'fast', }, }, body: { elements: [{ tag: 'markdown', element_id: STREAM_ELEMENT_ID, content: initialText, }], }, }; } export class VerifiedFeishuChannel { #client; #initialText; #fileUploadTimeoutMs; #fileMessageTimeoutMs; constructor({ client, initialText, fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS, fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS, }) { this.#client = client; this.#initialText = String(initialText ?? t(DEFAULT_INITIAL_TEXT)) || '…'; this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs'); this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs'); } async stream(chatId, input, options = {}) { if (typeof input?.markdown !== 'function') { throw new Error('Feishu stream requires a markdown producer'); } const cards = []; let activeCard = null; let rotating = false; try { activeCard = await this.#createStreamCard(chatId, options.replyTo); cards.push(activeCard); let lastContent = this.#initialText; // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得 // 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态; // 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于 // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。 const ensureActiveCard = async () => { if (!rotating) return activeCard; activeCard = await this.#createStreamCard(chatId, options.replyTo); cards.push(activeCard); rotating = false; return activeCard; }; const controller = { get messageId() { return activeCard.messageId; }, rotate: async () => { if (rotating) return; rotating = true; try { await this.#updateStreamCard( activeCard, `${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`, ); await this.#finishStreamCard(activeCard); } catch (error) { // 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。 console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message); } }, setContent: async (content) => { const next = String(content ?? '') || '…'; const card = await ensureActiveCard(); await this.#updateStreamCard(card, streamPreview(next)); // Updates are replaceable snapshots, including progress/tool text. // Retain the full latest snapshot even when its preview is unchanged. lastContent = next; }, }; await input.markdown(controller); const chunks = splitStreamContent(lastContent); for (const [index, chunk] of chunks.entries()) { const card = index === 0 ? await ensureActiveCard() : await this.#createStreamCard(chatId, options.replyTo); if (index > 0) cards.push(card); await this.#updateStreamCard(card, chunk); await this.#finishStreamCard(card); } return { messageId: cards[0].messageId, providerMessageIds: cards.map((card) => card.messageId), }; } catch (error) { // Preserve the existing provider-error fallback contract: the bridge // resends the completed answer, so remove any cards it would duplicate. for (const card of cards) await this.#recall(card.messageId); throw error; } } 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' || 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 = 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; }, { signal, timeoutMs: this.#fileUploadTimeoutMs, stage: 'upload', }); } catch (error) { if (signal?.aborted) throw abortReason(signal); throw fileDeliveryError('upload', error); } signal?.throwIfAborted(); 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, messageType); const content = JSON.stringify(messageType === 'image' ? { image_key: resourceKey } : { file_key: resourceKey }); const request = replyTo ? { path: { message_id: replyTo }, data: { msg_type: messageType, content, uuid }, } : { params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, msg_type: messageType, 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 resource 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, providerMessageIds: [messageId], artifacts: [{ artifactId: file.artifactId, outcome: 'sent', }], }); } async #createStreamCard(chatId, replyTo) { const content = streamPreview(this.#initialText); const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({ data: { type: 'card_json', data: JSON.stringify(streamingCard(content)), }, })); const cardId = response?.data?.card_id; if (!cardId) throw new Error('Feishu card.create returned no card_id'); const messageId = await this.#sendCard(chatId, cardId, replyTo); return { cardId, messageId, content, sequence: 0 }; } async #updateStreamCard(card, content) { if (content === card.content) return; const response = await this.#client.cardkit.v1.cardElement.content({ path: { card_id: card.cardId, element_id: STREAM_ELEMENT_ID }, data: { content, sequence: ++card.sequence, uuid: `content_${card.cardId}_${card.sequence}`, }, }); assertApiSuccess('Feishu cardElement.content', response); card.content = content; } async #finishStreamCard(card) { const response = await this.#client.cardkit.v1.card.settings({ path: { card_id: card.cardId }, data: { settings: JSON.stringify({ config: { streaming_mode: false, summary: { content: summaryOf(card.content) || t('回答完成') }, }, }), sequence: ++card.sequence, uuid: `settings_${card.cardId}_${card.sequence}`, }, }); assertApiSuccess('Feishu card.settings', response); } async #sendCard(chatId, cardId, replyTo) { const content = JSON.stringify({ type: 'card', data: { card_id: cardId } }); const response = replyTo ? await this.#client.im.v1.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'interactive', content }, }) : await this.#client.im.v1.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, msg_type: 'interactive', content }, }); assertApiSuccess('Feishu message send', response); const messageId = response?.data?.message_id; if (!messageId) throw new Error('Feishu message send returned no message_id'); return messageId; } async #recall(messageId) { try { const response = await this.#client.im.v1.message.delete({ path: { message_id: messageId }, }); assertApiSuccess('Feishu message delete', response); } catch (error) { console.warn('[bridge] unable to recall a failed streaming card:', error.message); } } async addReaction(messageId, emojiType) { const response = assertApiSuccess('Feishu reaction.create', await this.#client.im.v1.messageReaction.create({ path: { message_id: messageId }, data: { reaction_type: { emoji_type: emojiType } }, })); const reactionId = response?.data?.reaction_id; if (!reactionId) throw new Error('Feishu reaction.create returned no reaction_id'); return reactionId; } async removeReaction(messageId, reactionId) { assertApiSuccess('Feishu reaction.delete', await this.#client.im.v1.messageReaction.delete({ path: { message_id: messageId, reaction_id: reactionId }, })); } }