dsh-im-ops/src/channels/feishu/feishu-channel.mjs
2026-08-15 15:40:53 +08:00

153 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const STREAM_ELEMENT_ID = 'stream_md';
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const MAX_STREAM_CHARS = 28000;
function assertApiSuccess(operation, response) {
if (response?.code && response.code !== 0) {
throw new Error(`${operation} failed: ${response.msg || response.code}`);
}
return response;
}
function summaryOf(text) {
const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
}
function streamingCard(initialText) {
return {
schema: '2.0',
config: {
streaming_mode: true,
summary: { content: '正在生成…' },
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;
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
this.#client = client;
this.#initialText = initialText;
}
async stream(chatId, input, options = {}) {
if (typeof input?.markdown !== 'function') {
throw new Error('Feishu stream requires a markdown producer');
}
let messageId = null;
const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
data: {
type: 'card_json',
data: JSON.stringify(streamingCard(this.#initialText)),
},
}));
const cardId = cardResponse?.data?.card_id;
if (!cardId) throw new Error('Feishu card.create returned no card_id');
try {
messageId = await this.#sendCard(chatId, cardId, options.replyTo);
let sequence = 0;
let lastContent = this.#initialText;
const controller = {
messageId,
setContent: async (content) => {
const next = String(content ?? '') || '…';
if (next === lastContent) return;
if (next.length > MAX_STREAM_CHARS) {
throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`);
}
const response = await this.#client.cardkit.v1.cardElement.content({
path: { card_id: cardId, element_id: STREAM_ELEMENT_ID },
data: {
content: next,
sequence: ++sequence,
uuid: `content_${cardId}_${sequence}`,
},
});
assertApiSuccess('Feishu cardElement.content', response);
lastContent = next;
},
};
await input.markdown(controller);
const finishResponse = await this.#client.cardkit.v1.card.settings({
path: { card_id: cardId },
data: {
settings: JSON.stringify({
config: {
streaming_mode: false,
summary: { content: summaryOf(lastContent) || '回答完成' },
},
}),
sequence: ++sequence,
uuid: `settings_${cardId}_${sequence}`,
},
});
assertApiSuccess('Feishu card.settings', finishResponse);
return { messageId };
} catch (error) {
if (messageId) await this.#recall(messageId);
throw error;
}
}
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 },
}));
}
}