feat: support inbound files across all channels

This commit is contained in:
xmanrui 2026-08-23 22:39:26 +08:00
parent 29bcb67a64
commit 0a26d7df92
45 changed files with 2651 additions and 343 deletions

View file

@ -12,6 +12,10 @@ import {
imagePromptUserMessage,
promptContentForMessage,
} from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
const HELP_TEXT = [
'北汇星河 AIOS 已连接 DeepSeek Harness。',
'',
'直接发送文字或图片即可继续当前会话。',
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区',
@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
const processingReaction = this.#addReaction(messageId, 'OnIt');
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const commandRunner = isControlCommand(commandText)
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
await this.#finishReaction(messageId, processingReaction, 'ERROR');
await this.#send(
event.message.chat_id,
imagePromptUserMessage(error)
inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
).catch(() => undefined);
}
@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
{
signal: this.#signal,
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key),
control: { owner: this, key },
@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
const message = extractInboundMessage(event, this.#client);
const text = message.content;
const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
if (!text && !hasImages) {
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
const hasFiles = hasInboundFiles(message);
const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
if (!text && !hasImages && !hasFiles) {
await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
return;
}
@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
}
}
#interactionAskOptions(event, key) {
#interactionAskOptions(event, key, files) {
return {
timeoutMs: this.#replyTimeoutMs,
signal: this.#signal,
@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
requiresMention: event.message.chat_type !== 'p2p',
}),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files,
};
}
@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
let textReceipt;
let textSendError = null;
@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
markdown: async (controller) => {
promptStarted = true;
const askOptions = {
...this.#interactionAskOptions(event, key),
...this.#interactionAskOptions(event, key, message.files),
onUpdate: async (update) => {
await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
content,
createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key),
askOptions: this.#interactionAskOptions(event, key, message.files),
});
let textReceipt;
let textSendError = null;

View file

@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
}
}
async function readStream(stream, { signal }) {
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
throw new Error('Feishu file download returned no readable stream');
}
signal?.throwIfAborted();
const abort = () => stream.destroy?.(
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
);
signal?.addEventListener('abort', abort, { once: true });
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
signal?.throwIfAborted();
const data = Buffer.from(chunk);
size += data.length;
chunks.push(data);
}
signal?.throwIfAborted();
return Buffer.concat(chunks, size);
} finally {
signal?.removeEventListener('abort', abort);
}
}
function providerCode(value) {
if (!value || typeof value !== 'object') return null;
const code = value.code ?? value.error?.code;
@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
};
}
function feishuFileSource(event, client, file) {
const key = nonEmptyString(file?.file_key);
if (!key) return null;
return {
name: nonEmptyString(file?.file_name) ?? 'file',
async load({ signal } = {}) {
signal?.throwIfAborted();
const resource = await client?.im?.v1?.messageResource?.get?.({
path: {
message_id: event.message.message_id,
file_key: key,
},
params: { type: 'file' },
});
signal?.throwIfAborted();
return readStream(resource?.getReadableStream?.(), { signal });
},
};
}
export function extractInboundMessage(event, client) {
const messageType = event?.message?.message_type;
const parsed = parsedMessageContent(event);
@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
? nonEmptyString(parsed?.image_key)
: null;
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
return {
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
files: file ? [file] : [],
};
}