mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 17:20:53 +08:00
feat: support inbound files across all channels
This commit is contained in:
parent
29bcb67a64
commit
0a26d7df92
45 changed files with 2651 additions and 343 deletions
|
|
@ -435,6 +435,48 @@ export function createDingtalkApi({
|
|||
return request;
|
||||
}
|
||||
|
||||
async function messageFileDownloadUrl({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
kind,
|
||||
}) {
|
||||
const botCode = nonEmptyString(robotCode);
|
||||
const fileCode = nonEmptyString(downloadCode);
|
||||
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
||||
const token = await accessToken({ clientId, clientSecret, signal });
|
||||
let response;
|
||||
try {
|
||||
response = await requestJson(
|
||||
fetchImpl,
|
||||
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
|
||||
{
|
||||
body: { downloadCode: fileCode, robotCode: botCode },
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new DingtalkApiError(
|
||||
`${kind}-download-address-failed`,
|
||||
`钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
|
||||
{ cause: error, status: error?.status, providerCode: error?.providerCode },
|
||||
);
|
||||
}
|
||||
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
|
||||
if (!downloadUrl) {
|
||||
throw new DingtalkApiError(
|
||||
`invalid-${kind}-download`,
|
||||
`钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
|
||||
);
|
||||
}
|
||||
return secureDingtalkDownloadUrl(downloadUrl);
|
||||
}
|
||||
|
||||
function acquireCardRequestSlot(signal) {
|
||||
const acquire = async () => {
|
||||
const waitMs = Math.max(0, nextCardRequestAt - now());
|
||||
|
|
@ -568,36 +610,16 @@ export function createDingtalkApi({
|
|||
signal,
|
||||
maxBytes,
|
||||
}) {
|
||||
const botCode = nonEmptyString(robotCode);
|
||||
const fileCode = nonEmptyString(downloadCode);
|
||||
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
||||
const token = await accessToken({ clientId, clientSecret, signal });
|
||||
let response;
|
||||
const downloadUrl = await messageFileDownloadUrl({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
kind: 'image',
|
||||
});
|
||||
try {
|
||||
response = await requestJson(
|
||||
fetchImpl,
|
||||
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
|
||||
{
|
||||
body: { downloadCode: fileCode, robotCode: botCode },
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '图片下载地址',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new DingtalkApiError(
|
||||
'image-download-address-failed',
|
||||
'钉钉图片下载地址获取失败。',
|
||||
{ cause: error, status: error?.status, providerCode: error?.providerCode },
|
||||
);
|
||||
}
|
||||
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
|
||||
if (!downloadUrl) {
|
||||
throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
|
||||
}
|
||||
try {
|
||||
return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
|
||||
return await fetchImageBuffer(downloadUrl, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
maxBytes,
|
||||
|
|
@ -612,6 +634,50 @@ export function createDingtalkApi({
|
|||
}
|
||||
},
|
||||
|
||||
async downloadFile({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
}) {
|
||||
const downloadUrl = await messageFileDownloadUrl({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode,
|
||||
downloadCode,
|
||||
signal,
|
||||
kind: 'file',
|
||||
});
|
||||
if (downloadUrl.protocol !== 'https:') {
|
||||
throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
|
||||
}
|
||||
try {
|
||||
const response = await fetchImpl(downloadUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
await response?.body?.cancel?.().catch?.(() => undefined);
|
||||
throw new DingtalkApiError(
|
||||
'file-content-download-failed',
|
||||
`钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
|
||||
{ status: response?.status },
|
||||
);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (error instanceof DingtalkApiError) throw error;
|
||||
throw new DingtalkApiError(
|
||||
'file-content-download-failed',
|
||||
'钉钉文件内容下载失败。',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
|
||||
const appKey = nonEmptyString(clientId);
|
||||
const appSecret = nonEmptyString(clientSecret);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ import {
|
|||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from '../shared/image-prompt.mjs';
|
||||
import {
|
||||
hasInboundFiles,
|
||||
inboundFileUserMessage,
|
||||
prefetchInboundFiles,
|
||||
} from '../shared/inbound-file.mjs';
|
||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||
import {
|
||||
materializeOutboundArtifact,
|
||||
|
|
@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
|||
const HELP_TEXT = [
|
||||
'钉钉机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字或图片即可继续当前会话。',
|
||||
'直接发送文字、图片或文件即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
|
|
@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
|
|||
if (code) imageCodes.push(code);
|
||||
}
|
||||
}
|
||||
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
||||
return {
|
||||
content: text,
|
||||
images: imageCodes.map((downloadCode, index) => ({
|
||||
|
|
@ -193,6 +199,21 @@ export function dingtalkInboundMessage(message, {
|
|||
});
|
||||
},
|
||||
})),
|
||||
files: fileCode ? [{
|
||||
name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
|
||||
load: ({ signal } = {}) => {
|
||||
if (typeof api?.downloadFile !== 'function') {
|
||||
throw new Error('DingTalk API does not support file downloads');
|
||||
}
|
||||
return api.downloadFile({
|
||||
clientId,
|
||||
clientSecret,
|
||||
robotCode: message?.robotCode,
|
||||
downloadCode: fileCode,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +414,7 @@ export class DingtalkHarnessBridge {
|
|||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||
const commandRunner = isControlCommand(commandText)
|
||||
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
||||
? runControlCommand
|
||||
: (isModelCommand(commandText)
|
||||
? runModelCommand
|
||||
|
|
@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
|
|||
releaseMessageId = true,
|
||||
alreadyRecorded = false,
|
||||
} = {}) {
|
||||
let hasSafeReplyRoute = false;
|
||||
try {
|
||||
normalizeDingtalkSessionWebhook(message.sessionWebhook);
|
||||
hasSafeReplyRoute = true;
|
||||
} catch {
|
||||
// Keep the existing rejection path without downloading an unusable file.
|
||||
}
|
||||
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
|
||||
const preparedMessage = hasSafeReplyRoute && addressed
|
||||
? prefetchInboundFiles(dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
}), { signal: this.#signal })
|
||||
: undefined;
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
|
||||
.then(() => this.#process(message, messageId, sender, key, {
|
||||
alreadyRecorded,
|
||||
preparedMessage,
|
||||
}))
|
||||
.finally(() => {
|
||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
|
|
@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
|
|||
{
|
||||
signal: this.#signal,
|
||||
hasImages: hasInboundImages(prompt),
|
||||
hasFiles: hasInboundFiles(prompt),
|
||||
pendingInteraction: this.#pendingInteractions.has(key)
|
||||
|| this.#approvals.hasPending(key),
|
||||
control: { owner: this, key },
|
||||
|
|
@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
|
|||
this.#status.lastError = null;
|
||||
}
|
||||
|
||||
async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
|
||||
async #process(message, messageId, sender, key, {
|
||||
alreadyRecorded = false,
|
||||
preparedMessage,
|
||||
} = {}) {
|
||||
this.#signal?.throwIfAborted();
|
||||
if (!alreadyRecorded) {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
|
|
@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const promptMessage = dingtalkInboundMessage(message, {
|
||||
const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
|
||||
api: this.#api,
|
||||
clientId: this.#clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
});
|
||||
const text = promptMessage.content;
|
||||
const hasImages = hasInboundImages(promptMessage);
|
||||
const hasFiles = hasInboundFiles(promptMessage);
|
||||
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
||||
let cardStream = null;
|
||||
let cardStarted = false;
|
||||
try {
|
||||
if (!text && !hasImages) {
|
||||
await this.#send(sessionWebhook, '目前支持文字和图片消息。');
|
||||
if (!text && !hasImages && !hasFiles) {
|
||||
await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
|
||||
return;
|
||||
}
|
||||
|
||||
const command = text.toLowerCase();
|
||||
if (isPlainText && !hasImages && command === '/help') {
|
||||
if (isPlainText && !hasImages && !hasFiles && command === '/help') {
|
||||
await this.#send(sessionWebhook, HELP_TEXT);
|
||||
return;
|
||||
}
|
||||
if (isPlainText && !hasImages && command === '/status') {
|
||||
if (isPlainText && !hasImages && !hasFiles && command === '/status') {
|
||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||
return;
|
||||
}
|
||||
if (isPlainText && !hasImages && command === '/new') {
|
||||
if (isPlainText && !hasImages && !hasFiles && command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = isPlainText && !hasImages
|
||||
const workspaceCommand = isPlainText && !hasImages && !hasFiles
|
||||
? await runWorkspaceCommand(text, this.#harness, key)
|
||||
: null;
|
||||
if (workspaceCommand) {
|
||||
|
|
@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = isPlainText && !hasImages
|
||||
const compactCommand = isPlainText && !hasImages && !hasFiles
|
||||
? await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
|
|
@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
|
|||
requiresMention: String(message.conversationType) === '2',
|
||||
}),
|
||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||
files: promptMessage.files,
|
||||
},
|
||||
});
|
||||
const answerText = typeof answer === 'string' && answer.trim()
|
||||
|
|
@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
|
|||
safeErrorDiagnostic(error),
|
||||
);
|
||||
try {
|
||||
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
|
||||
const errorText = inboundFileUserMessage(error)
|
||||
?? dingtalkImageErrorUserMessage(error)
|
||||
?? CARD_ERROR_TEXT;
|
||||
const streamed = cardStarted && await cardStream.finish(errorText);
|
||||
if (!streamed) await this.#send(sessionWebhook, errorText);
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue