mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +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
329
lib/index.js
329
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -86,9 +86,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -98,6 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
deviceAuth,
|
deviceAuth,
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return stateFor(botConfig);
|
return stateFor(botConfig);
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -147,6 +148,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const proxyEnv = internals.proxyEnv ?? process.env;
|
const proxyEnv = internals.proxyEnv ?? process.env;
|
||||||
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
|
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -89,6 +90,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
qrAuth,
|
qrAuth,
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,10 @@ export async function createTokenProductionController(ctx, config, internals, de
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new ResolvedHarness({
|
const harness = new ResolvedHarness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -90,6 +91,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new ResolvedController({
|
const coreController = new ResolvedController({
|
||||||
credentials: ctx.credentials,
|
credentials: ctx.credentials,
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new ResolvedHarness({
|
const harness = new ResolvedHarness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -68,6 +69,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new ResolvedController({
|
const coreController = new ResolvedController({
|
||||||
credentials: ctx.credentials,
|
credentials: ctx.credentials,
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
qrAuth,
|
qrAuth,
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
api,
|
api,
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,10 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, {
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
|
||||||
controlExecutor: internals.controlExecutor,
|
controlExecutor: internals.controlExecutor,
|
||||||
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor: internals.fileIngressExecutor,
|
||||||
});
|
});
|
||||||
const harness = new Harness({
|
const harness = new Harness({
|
||||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||||
|
|
@ -95,6 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(commandExecutor ? { commandExecutor } : {}),
|
...(commandExecutor ? { commandExecutor } : {}),
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
||||||
});
|
});
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
configStore: observedConfigStore,
|
configStore: observedConfigStore,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InboundFileError,
|
||||||
|
stageInboundFiles,
|
||||||
|
} from '../../src/channels/shared/inbound-file.mjs';
|
||||||
|
|
||||||
function agentsFromContext(ctx) {
|
function agentsFromContext(ctx) {
|
||||||
if (typeof ctx?.get !== 'function') return undefined;
|
if (typeof ctx?.get !== 'function') return undefined;
|
||||||
let agents;
|
let agents;
|
||||||
|
|
@ -108,13 +113,30 @@ function createSessionMaintenanceExecutor(agents) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFileIngressExecutor(agents) {
|
||||||
|
return ({ sessionId, workspace, files, signal }) => {
|
||||||
|
const agent = agents.get(sessionId);
|
||||||
|
const attachedWorkspace = agent?.session?.header?.cwd;
|
||||||
|
const exactWorkspace = typeof attachedWorkspace === 'string' && attachedWorkspace
|
||||||
|
? attachedWorkspace
|
||||||
|
: workspace;
|
||||||
|
if (typeof exactWorkspace !== 'string' || !exactWorkspace) {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-workspace-unavailable',
|
||||||
|
'The Harness Session workspace is unavailable for inbound files.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build optional same-process Session executors without adding a hard Cordis
|
* Build optional same-process Session executors. File ingress also works for
|
||||||
* service injection. Fixtures and deployments without AgentRegistry preserve
|
* cold Sessions by using the authoritative cwd returned by session.list;
|
||||||
* the existing HTTP behavior.
|
* when an Agent is attached, its live Session header remains authoritative.
|
||||||
*/
|
*/
|
||||||
export function createHarnessSessionExecutors(ctx, provided = {}) {
|
export function createHarnessSessionExecutors(ctx, provided = {}) {
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = provided;
|
const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
|
||||||
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
||||||
throw new TypeError('controlExecutor must be a function');
|
throw new TypeError('controlExecutor must be a function');
|
||||||
}
|
}
|
||||||
|
|
@ -122,13 +144,18 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
|
||||||
&& typeof sessionMaintenanceExecutor !== 'function') {
|
&& typeof sessionMaintenanceExecutor !== 'function') {
|
||||||
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
||||||
}
|
}
|
||||||
|
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
||||||
|
throw new TypeError('fileIngressExecutor must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
const agents = controlExecutor && sessionMaintenanceExecutor
|
const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
|
||||||
? undefined
|
? undefined
|
||||||
: agentsFromContext(ctx);
|
: agentsFromContext(ctx);
|
||||||
return {
|
return {
|
||||||
controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
|
controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
|
||||||
sessionMaintenanceExecutor: sessionMaintenanceExecutor
|
sessionMaintenanceExecutor: sessionMaintenanceExecutor
|
||||||
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
|
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
|
||||||
|
fileIngressExecutor: fileIngressExecutor
|
||||||
|
?? createFileIngressExecutor(agents ?? { get: () => undefined }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,48 @@ export function createDingtalkApi({
|
||||||
return request;
|
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) {
|
function acquireCardRequestSlot(signal) {
|
||||||
const acquire = async () => {
|
const acquire = async () => {
|
||||||
const waitMs = Math.max(0, nextCardRequestAt - now());
|
const waitMs = Math.max(0, nextCardRequestAt - now());
|
||||||
|
|
@ -568,36 +610,16 @@ export function createDingtalkApi({
|
||||||
signal,
|
signal,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
}) {
|
}) {
|
||||||
const botCode = nonEmptyString(robotCode);
|
const downloadUrl = await messageFileDownloadUrl({
|
||||||
const fileCode = nonEmptyString(downloadCode);
|
clientId,
|
||||||
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
|
clientSecret,
|
||||||
const token = await accessToken({ clientId, clientSecret, signal });
|
robotCode,
|
||||||
let response;
|
downloadCode,
|
||||||
|
signal,
|
||||||
|
kind: 'image',
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
response = await requestJson(
|
return await fetchImageBuffer(downloadUrl, {
|
||||||
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), {
|
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
signal,
|
signal,
|
||||||
maxBytes,
|
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 }) {
|
async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
|
||||||
const appKey = nonEmptyString(clientId);
|
const appKey = nonEmptyString(clientId);
|
||||||
const appSecret = nonEmptyString(clientSecret);
|
const appSecret = nonEmptyString(clientSecret);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from '../shared/image-prompt.mjs';
|
} from '../shared/image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
prefetchInboundFiles,
|
||||||
|
} from '../shared/inbound-file.mjs';
|
||||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
materializeOutboundArtifact,
|
materializeOutboundArtifact,
|
||||||
|
|
@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'钉钉机器人已连接 DeepSeek Harness。',
|
'钉钉机器人已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
'直接发送文字或图片即可继续当前会话。',
|
'直接发送文字、图片或文件即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
|
||||||
if (code) imageCodes.push(code);
|
if (code) imageCodes.push(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
|
||||||
return {
|
return {
|
||||||
content: text,
|
content: text,
|
||||||
images: imageCodes.map((downloadCode, index) => ({
|
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,
|
clientSecret: this.#clientSecret,
|
||||||
});
|
});
|
||||||
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
const commandText = nonEmptyString(promptMessage.content) ?? '';
|
||||||
const commandRunner = isControlCommand(commandText)
|
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(commandText)
|
: (isModelCommand(commandText)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
|
||||||
releaseMessageId = true,
|
releaseMessageId = true,
|
||||||
alreadyRecorded = false,
|
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 previous = this.#queues.get(key) ?? Promise.resolve();
|
||||||
const current = previous
|
const current = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
|
.then(() => this.#process(message, messageId, sender, key, {
|
||||||
|
alreadyRecorded,
|
||||||
|
preparedMessage,
|
||||||
|
}))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||||
|
|
@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
|
||||||
{
|
{
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasInboundImages(prompt),
|
hasImages: hasInboundImages(prompt),
|
||||||
|
hasFiles: hasInboundFiles(prompt),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
|
||||||
this.#status.lastError = null;
|
this.#status.lastError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
|
async #process(message, messageId, sender, key, {
|
||||||
|
alreadyRecorded = false,
|
||||||
|
preparedMessage,
|
||||||
|
} = {}) {
|
||||||
this.#signal?.throwIfAborted();
|
this.#signal?.throwIfAborted();
|
||||||
if (!alreadyRecorded) {
|
if (!alreadyRecorded) {
|
||||||
if (this.#state.hasSeen(messageId)) return;
|
if (this.#state.hasSeen(messageId)) return;
|
||||||
|
|
@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptMessage = dingtalkInboundMessage(message, {
|
const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
|
||||||
api: this.#api,
|
api: this.#api,
|
||||||
clientId: this.#clientId,
|
clientId: this.#clientId,
|
||||||
clientSecret: this.#clientSecret,
|
clientSecret: this.#clientSecret,
|
||||||
});
|
});
|
||||||
const text = promptMessage.content;
|
const text = promptMessage.content;
|
||||||
const hasImages = hasInboundImages(promptMessage);
|
const hasImages = hasInboundImages(promptMessage);
|
||||||
|
const hasFiles = hasInboundFiles(promptMessage);
|
||||||
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
|
||||||
let cardStream = null;
|
let cardStream = null;
|
||||||
let cardStarted = false;
|
let cardStarted = false;
|
||||||
try {
|
try {
|
||||||
if (!text && !hasImages) {
|
if (!text && !hasImages && !hasFiles) {
|
||||||
await this.#send(sessionWebhook, '目前支持文字和图片消息。');
|
await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = text.toLowerCase();
|
const command = text.toLowerCase();
|
||||||
if (isPlainText && !hasImages && command === '/help') {
|
if (isPlainText && !hasImages && !hasFiles && command === '/help') {
|
||||||
await this.#send(sessionWebhook, HELP_TEXT);
|
await this.#send(sessionWebhook, HELP_TEXT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isPlainText && !hasImages && command === '/status') {
|
if (isPlainText && !hasImages && !hasFiles && command === '/status') {
|
||||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isPlainText && !hasImages && command === '/new') {
|
if (isPlainText && !hasImages && !hasFiles && command === '/new') {
|
||||||
await this.#state.clearSession(key);
|
await this.#state.clearSession(key);
|
||||||
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceCommand = isPlainText && !hasImages
|
const workspaceCommand = isPlainText && !hasImages && !hasFiles
|
||||||
? await runWorkspaceCommand(text, this.#harness, key)
|
? await runWorkspaceCommand(text, this.#harness, key)
|
||||||
: null;
|
: null;
|
||||||
if (workspaceCommand) {
|
if (workspaceCommand) {
|
||||||
|
|
@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const compactCommand = isPlainText && !hasImages
|
const compactCommand = isPlainText && !hasImages && !hasFiles
|
||||||
? await runCompactCommand(
|
? await runCompactCommand(
|
||||||
text,
|
text,
|
||||||
this.#harness,
|
this.#harness,
|
||||||
|
|
@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
|
||||||
requiresMention: String(message.conversationType) === '2',
|
requiresMention: String(message.conversationType) === '2',
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files: promptMessage.files,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const answerText = typeof answer === 'string' && answer.trim()
|
const answerText = typeof answer === 'string' && answer.trim()
|
||||||
|
|
@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
|
||||||
safeErrorDiagnostic(error),
|
safeErrorDiagnostic(error),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
|
const errorText = inboundFileUserMessage(error)
|
||||||
|
?? dingtalkImageErrorUserMessage(error)
|
||||||
|
?? CARD_ERROR_TEXT;
|
||||||
const streamed = cardStarted && await cardStream.finish(errorText);
|
const streamed = cardStarted && await cardStream.finish(errorText);
|
||||||
if (!streamed) await this.#send(sessionWebhook, errorText);
|
if (!streamed) await this.#send(sessionWebhook, errorText);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||||
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
||||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||||
import { DiscordApi } from './discord-api.mjs';
|
import { DiscordApi } from './discord-api.mjs';
|
||||||
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
||||||
|
|
@ -88,6 +89,25 @@ function discordImageSource(attachment, fetchImpl) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function discordFileSource(attachment, fetchImpl) {
|
||||||
|
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
|
||||||
|
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
|
||||||
|
return {
|
||||||
|
name: typeof attachment.filename === 'string' && attachment.filename
|
||||||
|
? attachment.filename : String(attachment.id ?? 'discord-file'),
|
||||||
|
...(mediaType ? { mediaType } : {}),
|
||||||
|
size: attachmentSize(attachment.size),
|
||||||
|
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
allowedHosts: DISCORD_IMAGE_HOSTS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
|
||||||
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
||||||
const direct = !message.guild_id;
|
const direct = !message.guild_id;
|
||||||
|
|
@ -103,6 +123,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
||||||
images: Array.isArray(message.attachments)
|
images: Array.isArray(message.attachments)
|
||||||
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
|
files: Array.isArray(message.attachments)
|
||||||
|
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
|
||||||
|
: [],
|
||||||
addressed,
|
addressed,
|
||||||
replyTarget: {
|
replyTarget: {
|
||||||
channelId: String(message.channel_id),
|
channelId: String(message.channel_id),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from '../shared/image-prompt.mjs';
|
} from '../shared/image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
} from '../shared/inbound-file.mjs';
|
||||||
import {
|
import {
|
||||||
harnessAnswerForQuestion,
|
harnessAnswerForQuestion,
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
|
|
@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
'直接发送文字或图片即可继续当前会话。',
|
'直接发送文字、图片或文件即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
|
||||||
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
const processingReaction = this.#addReaction(messageId, 'OnIt');
|
||||||
const commandMessage = extractInboundMessage(event, this.#client);
|
const commandMessage = extractInboundMessage(event, this.#client);
|
||||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||||
const commandRunner = isControlCommand(commandText)
|
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(commandText)
|
: (isModelCommand(commandText)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
|
||||||
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
await this.#finishReaction(messageId, processingReaction, 'ERROR');
|
||||||
await this.#send(
|
await this.#send(
|
||||||
event.message.chat_id,
|
event.message.chat_id,
|
||||||
imagePromptUserMessage(error)
|
inboundFileUserMessage(error)
|
||||||
|
?? imagePromptUserMessage(error)
|
||||||
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
|
||||||
{
|
{
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasInboundImages(message),
|
hasImages: hasInboundImages(message),
|
||||||
|
hasFiles: hasInboundFiles(message),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
|
||||||
const message = extractInboundMessage(event, this.#client);
|
const message = extractInboundMessage(event, this.#client);
|
||||||
const text = message.content;
|
const text = message.content;
|
||||||
const hasImages = hasInboundImages(message);
|
const hasImages = hasInboundImages(message);
|
||||||
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
|
const hasFiles = hasInboundFiles(message);
|
||||||
if (!text && !hasImages) {
|
const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
|
||||||
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
|
if (!text && !hasImages && !hasFiles) {
|
||||||
|
await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#interactionAskOptions(event, key) {
|
#interactionAskOptions(event, key, files) {
|
||||||
return {
|
return {
|
||||||
timeoutMs: this.#replyTimeoutMs,
|
timeoutMs: this.#replyTimeoutMs,
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
|
|
@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
|
||||||
requiresMention: event.message.chat_type !== 'p2p',
|
requiresMention: event.message.chat_type !== 'p2p',
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
|
||||||
content,
|
content,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: this.#interactionAskOptions(event, key),
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||||
});
|
});
|
||||||
let textReceipt;
|
let textReceipt;
|
||||||
let textSendError = null;
|
let textSendError = null;
|
||||||
|
|
@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
|
||||||
markdown: async (controller) => {
|
markdown: async (controller) => {
|
||||||
promptStarted = true;
|
promptStarted = true;
|
||||||
const askOptions = {
|
const askOptions = {
|
||||||
...this.#interactionAskOptions(event, key),
|
...this.#interactionAskOptions(event, key, message.files),
|
||||||
onUpdate: async (update) => {
|
onUpdate: async (update) => {
|
||||||
await controller.setContent(this.#progressText(update));
|
await controller.setContent(this.#progressText(update));
|
||||||
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
|
||||||
|
|
@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
|
||||||
content,
|
content,
|
||||||
createOptions: { signal: this.#signal },
|
createOptions: { signal: this.#signal },
|
||||||
existsOptions: { signal: this.#signal },
|
existsOptions: { signal: this.#signal },
|
||||||
askOptions: this.#interactionAskOptions(event, key),
|
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||||
});
|
});
|
||||||
let textReceipt;
|
let textReceipt;
|
||||||
let textSendError = null;
|
let textSendError = null;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function providerCode(value) {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const code = value.code ?? value.error?.code;
|
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) {
|
export function extractInboundMessage(event, client) {
|
||||||
const messageType = event?.message?.message_type;
|
const messageType = event?.message?.message_type;
|
||||||
const parsed = parsedMessageContent(event);
|
const parsed = parsedMessageContent(event);
|
||||||
|
|
@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
|
||||||
? nonEmptyString(parsed?.image_key)
|
? nonEmptyString(parsed?.image_key)
|
||||||
: null;
|
: null;
|
||||||
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
||||||
|
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
||||||
return {
|
return {
|
||||||
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
||||||
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
||||||
|
files: file ? [file] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from '../shared/image-prompt.mjs';
|
} from '../shared/image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
prefetchInboundFiles,
|
||||||
|
} from '../shared/inbound-file.mjs';
|
||||||
import {
|
import {
|
||||||
materializeOutboundArtifact,
|
materializeOutboundArtifact,
|
||||||
releaseOutboundArtifact,
|
releaseOutboundArtifact,
|
||||||
|
|
@ -55,7 +60,7 @@ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'QQ 机器人已连接 DeepSeek Harness。',
|
'QQ 机器人已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
'直接发送文字或图片即可继续当前会话。',
|
'直接发送文字、图片或文件即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -100,32 +105,63 @@ function hasQqImageAttachments(message) {
|
||||||
&& message.attachments.some(isQqImageAttachment);
|
&& message.attachments.some(isQqImageAttachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasQqFileAttachments(message) {
|
||||||
|
return Array.isArray(message?.attachments)
|
||||||
|
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
|
||||||
|
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
|
||||||
|
const response = await fetchImpl(new URL(normalizedUrl), {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!response?.ok) {
|
||||||
|
await response?.body?.cancel?.().catch?.(() => undefined);
|
||||||
|
throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
/** Convert QQ's attachment metadata into lazily downloaded image references. */
|
/** Convert QQ's attachment metadata into lazily downloaded image references. */
|
||||||
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
|
||||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||||
const images = [];
|
const images = [];
|
||||||
|
const files = [];
|
||||||
for (const attachment of message?.attachments ?? []) {
|
for (const attachment of message?.attachments ?? []) {
|
||||||
if (!isQqImageAttachment(attachment)) continue;
|
|
||||||
const url = nonEmptyString(attachment?.url);
|
const url = nonEmptyString(attachment?.url);
|
||||||
const name = nonEmptyString(attachment?.filename) ?? undefined;
|
const name = nonEmptyString(attachment?.filename) ?? undefined;
|
||||||
const mediaType = attachmentMediaType(attachment);
|
const mediaType = attachmentMediaType(attachment);
|
||||||
const declaredSize = Number(attachment?.size);
|
const declaredSize = Number(attachment?.size);
|
||||||
images.push({
|
if (isQqImageAttachment(attachment)) {
|
||||||
...(name ? { name } : {}),
|
images.push({
|
||||||
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
|
...(name ? { name } : {}),
|
||||||
|
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
|
||||||
|
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
||||||
|
load: ({ signal, maxBytes }) => {
|
||||||
|
if (!url) throw new Error('QQ image attachment has no download URL');
|
||||||
|
return fetchImageBuffer(url, {
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
maxBytes,
|
||||||
|
allowedHosts: QQ_IMAGE_HOSTS,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push({
|
||||||
|
name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
|
||||||
|
...(mediaType?.includes('/') ? { mediaType } : {}),
|
||||||
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
||||||
load: ({ signal, maxBytes }) => {
|
load: ({ signal } = {}) => {
|
||||||
if (!url) throw new Error('QQ image attachment has no download URL');
|
if (!url) throw new Error('QQ file attachment has no download URL');
|
||||||
return fetchImageBuffer(url, {
|
return fetchQqFileBuffer(url, { fetchImpl, signal });
|
||||||
fetchImpl,
|
|
||||||
signal,
|
|
||||||
maxBytes,
|
|
||||||
allowedHosts: QQ_IMAGE_HOSTS,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { content: safeText(message), images };
|
return { content: safeText(message), images, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nonEmptyString(value) {
|
function nonEmptyString(value) {
|
||||||
|
|
@ -213,6 +249,7 @@ function canClaimInteractionReply(message, pending) {
|
||||||
&& nonEmptyString(message?.senderId) === pending.actor
|
&& nonEmptyString(message?.senderId) === pending.actor
|
||||||
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
|
||||||
&& !hasQqImageAttachments(message)
|
&& !hasQqImageAttachments(message)
|
||||||
|
&& !hasQqFileAttachments(message)
|
||||||
&& nonEmptyString(safeText(message));
|
&& nonEmptyString(safeText(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +340,7 @@ export class QqHarnessBridge {
|
||||||
}
|
}
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
const commandText = safeText(message);
|
const commandText = safeText(message);
|
||||||
const commandRunner = isControlCommand(commandText)
|
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(commandText)
|
: (isModelCommand(commandText)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -336,7 +373,7 @@ export class QqHarnessBridge {
|
||||||
key,
|
key,
|
||||||
actor: sender,
|
actor: sender,
|
||||||
messageId,
|
messageId,
|
||||||
text: hasQqImageAttachments(message) ? '' : safeText(message),
|
text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
|
||||||
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
|
||||||
hasPendingQuestion: Boolean(pending),
|
hasPendingQuestion: Boolean(pending),
|
||||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
|
@ -391,10 +428,19 @@ export class QqHarnessBridge {
|
||||||
releaseMessageId = true,
|
releaseMessageId = true,
|
||||||
alreadyRecorded = false,
|
alreadyRecorded = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
|
||||||
|
const addressed = message.kind !== 'group'
|
||||||
|
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
|
||||||
|
const preparedMessage = allowed && addressed
|
||||||
|
? prefetchInboundFiles(
|
||||||
|
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
|
||||||
|
{ signal: this.#signal },
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||||
const current = previous
|
const current = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(() => this.#process(message, key, { alreadyRecorded }))
|
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||||
|
|
@ -423,6 +469,7 @@ export class QqHarnessBridge {
|
||||||
const result = await runner(text, this.#harness, this.#state, key, {
|
const result = await runner(text, this.#harness, this.#state, key, {
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasQqImageAttachments(message),
|
hasImages: hasQqImageAttachments(message),
|
||||||
|
hasFiles: hasQqFileAttachments(message),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -520,7 +567,7 @@ export class QqHarnessBridge {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async #process(message, key, { alreadyRecorded = false } = {}) {
|
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||||
if (this.#signal?.aborted) return;
|
if (this.#signal?.aborted) return;
|
||||||
const messageId = nonEmptyString(message?.messageId);
|
const messageId = nonEmptyString(message?.messageId);
|
||||||
const sender = nonEmptyString(message?.senderId);
|
const sender = nonEmptyString(message?.senderId);
|
||||||
|
|
@ -539,35 +586,37 @@ export class QqHarnessBridge {
|
||||||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||||
|
|
||||||
const target = message.replyTarget;
|
const target = message.replyTarget;
|
||||||
const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
|
const promptMessage = preparedMessage
|
||||||
|
?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
|
||||||
const text = promptMessage.content;
|
const text = promptMessage.content;
|
||||||
const hasImages = hasInboundImages(promptMessage);
|
const hasImages = hasInboundImages(promptMessage);
|
||||||
|
const hasFiles = hasInboundFiles(promptMessage);
|
||||||
let stream = null;
|
let stream = null;
|
||||||
try {
|
try {
|
||||||
if (!text && !hasImages) {
|
if (!text && !hasImages && !hasFiles) {
|
||||||
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const command = text.toLowerCase();
|
const command = text.toLowerCase();
|
||||||
if (!hasImages && command === '/help') {
|
if (!hasImages && !hasFiles && command === '/help') {
|
||||||
await this.#bot.sendText(target, HELP_TEXT);
|
await this.#bot.sendText(target, HELP_TEXT);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/status') {
|
if (!hasImages && !hasFiles && command === '/status') {
|
||||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/new') {
|
if (!hasImages && !hasFiles && command === '/new') {
|
||||||
await this.#state.clearSession(key);
|
await this.#state.clearSession(key);
|
||||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceCommand = hasImages
|
const workspaceCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runWorkspaceCommand(text, this.#harness, key);
|
: await runWorkspaceCommand(text, this.#harness, key);
|
||||||
if (workspaceCommand) {
|
if (workspaceCommand) {
|
||||||
|
|
@ -577,7 +626,7 @@ export class QqHarnessBridge {
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const compactCommand = hasImages
|
const compactCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runCompactCommand(
|
: await runCompactCommand(
|
||||||
text,
|
text,
|
||||||
|
|
@ -632,6 +681,7 @@ export class QqHarnessBridge {
|
||||||
requiresMention: message.kind === 'group',
|
requiresMention: message.kind === 'group',
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files: promptMessage.files,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -708,7 +758,9 @@ export class QqHarnessBridge {
|
||||||
try {
|
try {
|
||||||
await this.#bot.sendText(
|
await this.#bot.sendText(
|
||||||
target,
|
target,
|
||||||
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
|
inboundFileUserMessage(error)
|
||||||
|
?? imagePromptUserMessage(error)
|
||||||
|
?? '消息处理失败,请稍后重试。',
|
||||||
);
|
);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
} catch (sendError) {
|
} catch (sendError) {
|
||||||
|
|
@ -734,7 +786,7 @@ export class QqHarnessBridge {
|
||||||
|
|
||||||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||||
const text = nonEmptyString(safeText(message));
|
const text = nonEmptyString(safeText(message));
|
||||||
if (!text || hasQqImageAttachments(message)) {
|
if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
|
||||||
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
src/channels/shared/file-download.mjs
Normal file
64
src/channels/shared/file-download.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
async function cancelResponseBody(response) {
|
||||||
|
try {
|
||||||
|
await response?.body?.cancel?.();
|
||||||
|
} catch {
|
||||||
|
// Preserve the original download failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostedByMessagingPlatform(target, allowedHosts) {
|
||||||
|
return !Array.isArray(allowedHosts) || allowedHosts.some((rule) => (
|
||||||
|
typeof rule === 'string'
|
||||||
|
&& (target.hostname === rule
|
||||||
|
|| (rule.startsWith('.')
|
||||||
|
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a channel-hosted ordinary file as a stream.
|
||||||
|
*
|
||||||
|
* This deliberately has no plugin-defined size, type, count, or download-time
|
||||||
|
* limit. The caller owns cancellation through its AbortSignal and the channel
|
||||||
|
* remains the authority for its own file limits.
|
||||||
|
*/
|
||||||
|
export async function fetchFileStream(url, {
|
||||||
|
fetchImpl = fetch,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
allowedHosts,
|
||||||
|
} = {}) {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (target.protocol !== 'https:') throw new Error('File download URL must use HTTPS');
|
||||||
|
if (!hostedByMessagingPlatform(target, allowedHosts)) {
|
||||||
|
throw new Error('File download URL is not hosted by the messaging platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(target, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
|
||||||
|
await cancelResponseBody(response);
|
||||||
|
const error = new Error(`File download redirect was blocked (HTTP ${response.status})`);
|
||||||
|
error.code = 'file-redirect-blocked';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!response?.ok) {
|
||||||
|
await cancelResponseBody(response);
|
||||||
|
const error = new Error(`File download failed with HTTP ${response?.status ?? 'unknown'}`);
|
||||||
|
error.code = 'file-http-error';
|
||||||
|
error.status = response?.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (response.body?.[Symbol.asyncIterator]) return { stream: response.body };
|
||||||
|
if (typeof response.arrayBuffer === 'function') {
|
||||||
|
const data = Buffer.from(await response.arrayBuffer());
|
||||||
|
return {
|
||||||
|
stream: (async function* fileBody() { yield data; }()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('File download returned no readable body');
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||||
import { isAbsolute } from 'node:path';
|
import { isAbsolute } from 'node:path';
|
||||||
|
|
||||||
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||||
|
import {
|
||||||
|
appendInboundFilesToPrompt,
|
||||||
|
InboundFileError,
|
||||||
|
} from './inbound-file.mjs';
|
||||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||||
|
|
||||||
// Every channel plugin runs in the same Host process. Sharing ownership by
|
// Every channel plugin runs in the same Host process. Sharing ownership by
|
||||||
|
|
@ -466,6 +470,7 @@ export class HarnessClient {
|
||||||
#commandExecutor;
|
#commandExecutor;
|
||||||
#controlExecutor;
|
#controlExecutor;
|
||||||
#sessionMaintenanceExecutor;
|
#sessionMaintenanceExecutor;
|
||||||
|
#fileIngressExecutor;
|
||||||
#managedProcess = null;
|
#managedProcess = null;
|
||||||
#interactionRegistry;
|
#interactionRegistry;
|
||||||
#interactionOwnerships;
|
#interactionOwnerships;
|
||||||
|
|
@ -486,6 +491,7 @@ export class HarnessClient {
|
||||||
commandExecutor,
|
commandExecutor,
|
||||||
controlExecutor,
|
controlExecutor,
|
||||||
sessionMaintenanceExecutor,
|
sessionMaintenanceExecutor,
|
||||||
|
fileIngressExecutor,
|
||||||
}) {
|
}) {
|
||||||
if (typeof createWebSocket !== 'function') {
|
if (typeof createWebSocket !== 'function') {
|
||||||
throw new TypeError('createWebSocket must be a function');
|
throw new TypeError('createWebSocket must be a function');
|
||||||
|
|
@ -509,6 +515,9 @@ export class HarnessClient {
|
||||||
&& typeof sessionMaintenanceExecutor !== 'function') {
|
&& typeof sessionMaintenanceExecutor !== 'function') {
|
||||||
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
throw new TypeError('sessionMaintenanceExecutor must be a function');
|
||||||
}
|
}
|
||||||
|
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
||||||
|
throw new TypeError('fileIngressExecutor must be a function');
|
||||||
|
}
|
||||||
this.#baseUrl = new URL(baseUrl);
|
this.#baseUrl = new URL(baseUrl);
|
||||||
this.#workspace = workspace;
|
this.#workspace = workspace;
|
||||||
// Keep an omitted preset absent so session.create resolves the Host's current default.
|
// Keep an omitted preset absent so session.create resolves the Host's current default.
|
||||||
|
|
@ -523,6 +532,7 @@ export class HarnessClient {
|
||||||
this.#commandExecutor = commandExecutor;
|
this.#commandExecutor = commandExecutor;
|
||||||
this.#controlExecutor = controlExecutor;
|
this.#controlExecutor = controlExecutor;
|
||||||
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
|
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
|
||||||
|
this.#fileIngressExecutor = fileIngressExecutor;
|
||||||
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
||||||
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
||||||
this.#interactionClaims = this.#interactionRegistry.claims;
|
this.#interactionClaims = this.#interactionRegistry.claims;
|
||||||
|
|
@ -1034,6 +1044,7 @@ export class HarnessClient {
|
||||||
? options.onInteractionResolved
|
? options.onInteractionResolved
|
||||||
: undefined;
|
: undefined;
|
||||||
const control = normalizeControl(options.control);
|
const control = normalizeControl(options.control);
|
||||||
|
const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
|
||||||
await this.ensureRunning({ signal });
|
await this.ensureRunning({ signal });
|
||||||
const before = await this.rpc(
|
const before = await this.rpc(
|
||||||
'session.history',
|
'session.history',
|
||||||
|
|
@ -1075,6 +1086,9 @@ export class HarnessClient {
|
||||||
let interactionTask = null;
|
let interactionTask = null;
|
||||||
let artifactsDelivered = false;
|
let artifactsDelivered = false;
|
||||||
let deliveredArtifactCount = 0;
|
let deliveredArtifactCount = 0;
|
||||||
|
let stagedInboundFiles = null;
|
||||||
|
let promptAccepted = false;
|
||||||
|
let turnFinished = false;
|
||||||
|
|
||||||
const deliverArtifacts = async () => {
|
const deliverArtifacts = async () => {
|
||||||
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
|
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
|
||||||
|
|
@ -1103,6 +1117,30 @@ export class HarnessClient {
|
||||||
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (inboundFiles.length > 0) {
|
||||||
|
if (!this.#fileIngressExecutor) {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-ingress-unavailable',
|
||||||
|
'Harness file ingress is unavailable in this Host process.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sessionList = await this.rpc(
|
||||||
|
'session.list',
|
||||||
|
{},
|
||||||
|
30_000,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const sessionWorkspace = sessionList?.items?.find(
|
||||||
|
(item) => item?.sessionId === sessionId,
|
||||||
|
)?.cwd;
|
||||||
|
stagedInboundFiles = await this.#fileIngressExecutor({
|
||||||
|
sessionId,
|
||||||
|
workspace: sessionWorkspace,
|
||||||
|
files: inboundFiles,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
|
||||||
|
}
|
||||||
if (interactionSignal) {
|
if (interactionSignal) {
|
||||||
let markOpen;
|
let markOpen;
|
||||||
const opened = new Promise((resolve) => { markOpen = resolve; });
|
const opened = new Promise((resolve) => { markOpen = resolve; });
|
||||||
|
|
@ -1134,6 +1172,7 @@ export class HarnessClient {
|
||||||
content,
|
content,
|
||||||
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
}, 30_000, { rpcId: promptRpcId, signal });
|
}, 30_000, { rpcId: promptRpcId, signal });
|
||||||
|
promptAccepted = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
@ -1159,6 +1198,7 @@ export class HarnessClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!tracker.finished) continue;
|
if (!tracker.finished) continue;
|
||||||
|
turnFinished = true;
|
||||||
// An accepted /stop revokes attachment delivery even when Harness
|
// An accepted /stop revokes attachment delivery even when Harness
|
||||||
// preserved a useful partial text answer for the existing UX.
|
// preserved a useful partial text answer for the existing UX.
|
||||||
const artifactCount = ownership?.stopRequested
|
const artifactCount = ownership?.stopRequested
|
||||||
|
|
@ -1185,6 +1225,11 @@ export class HarnessClient {
|
||||||
throw turnStoppedError();
|
throw turnStoppedError();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
|
||||||
|
await stagedInboundFiles.cleanup().catch((error) => {
|
||||||
|
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
closeArtifactConsumer();
|
closeArtifactConsumer();
|
||||||
if (ownership) {
|
if (ownership) {
|
||||||
this.#unregisterControlOwnership(ownership);
|
this.#unregisterControlOwnership(ownership);
|
||||||
|
|
|
||||||
206
src/channels/shared/inbound-file.mjs
Normal file
206
src/channels/shared/inbound-file.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
|
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
|
||||||
|
|
||||||
|
export class InboundFileError extends Error {
|
||||||
|
constructor(code, message, userMessage = '文件接收失败,请重新发送后再试。', options = {}) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'InboundFileError';
|
||||||
|
this.code = code;
|
||||||
|
this.userMessage = userMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSources(message) {
|
||||||
|
return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(value, fallback) {
|
||||||
|
if (typeof value !== 'string') return fallback;
|
||||||
|
const cleaned = value
|
||||||
|
.replaceAll('\\', '/')
|
||||||
|
.split('/')
|
||||||
|
.at(-1)
|
||||||
|
?.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||||
|
.trim();
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageName(value, index) {
|
||||||
|
const cleaned = displayName(value, 'file')
|
||||||
|
.replace(/[^\p{L}\p{N}._ -]/gu, '_')
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.slice(0, 160) || 'file';
|
||||||
|
return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadedFile(value) {
|
||||||
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
||||||
|
return { data: Buffer.from(value) };
|
||||||
|
}
|
||||||
|
const raw = value?.data ?? value?.buffer;
|
||||||
|
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
||||||
|
return {
|
||||||
|
data: Buffer.from(raw),
|
||||||
|
name: value?.name ?? value?.filename,
|
||||||
|
mediaType: value?.mediaType ?? value?.mimetype,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stream = value?.stream ?? value;
|
||||||
|
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
name: value?.name ?? value?.filename,
|
||||||
|
mediaType: value?.mediaType ?? value?.mimetype,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasInboundFiles(message) {
|
||||||
|
return fileSources(message).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start provider downloads immediately while preserving the lazy file-source contract. */
|
||||||
|
export function prefetchInboundFiles(message, { signal } = {}) {
|
||||||
|
const sources = fileSources(message);
|
||||||
|
if (sources.length === 0) return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
files: sources.map((source) => {
|
||||||
|
if (source?.data !== undefined || typeof source?.load !== 'function') return source;
|
||||||
|
let download;
|
||||||
|
try {
|
||||||
|
download = Promise.resolve(source.load({ signal }));
|
||||||
|
} catch (error) {
|
||||||
|
download = Promise.reject(error);
|
||||||
|
}
|
||||||
|
download.catch(() => undefined);
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
async load({ signal: loadSignal } = {}) {
|
||||||
|
loadSignal?.throwIfAborted();
|
||||||
|
const result = await download;
|
||||||
|
loadSignal?.throwIfAborted();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageInboundFiles(message, {
|
||||||
|
workspace,
|
||||||
|
signal,
|
||||||
|
} = {}) {
|
||||||
|
const sources = fileSources(message);
|
||||||
|
if (sources.length === 0) return null;
|
||||||
|
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-workspace-unavailable',
|
||||||
|
'The Harness Session workspace is unavailable for inbound files.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const root = resolve(workspace, FILES_DIRECTORY);
|
||||||
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||||
|
const directory = await mkdtemp(join(root, 'turn-'));
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [index, source] of sources.entries()) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = source?.data === undefined
|
||||||
|
? await source?.load?.({ signal })
|
||||||
|
: source.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-download-failed',
|
||||||
|
`Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
|
||||||
|
'文件下载失败,请重新发送后再试。',
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = loadedFile(value);
|
||||||
|
if (!loaded) {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-data-invalid',
|
||||||
|
`Inbound file ${index + 1} returned no readable data.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
|
||||||
|
const path = join(directory, storageName(name, index));
|
||||||
|
if (loaded.data) {
|
||||||
|
await writeFile(path, loaded.data, { mode: 0o600, signal });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await pipeline(
|
||||||
|
loaded.stream,
|
||||||
|
createWriteStream(path, { flags: 'wx', mode: 0o600 }),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-download-failed',
|
||||||
|
`Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
|
||||||
|
'文件下载失败,请重新发送后再试。',
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const relativePath = relative(resolve(workspace), path);
|
||||||
|
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-path-invalid',
|
||||||
|
'The staged inbound file escaped the Harness Session workspace.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
files.push(Object.freeze({
|
||||||
|
name,
|
||||||
|
path: relativePath,
|
||||||
|
...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
|
||||||
|
&& (loaded.mediaType ?? source.mediaType).trim()
|
||||||
|
? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
files: Object.freeze(files),
|
||||||
|
async cleanup() {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendInboundFilesToPrompt(prompt, staged) {
|
||||||
|
if (!staged?.files?.length) return prompt;
|
||||||
|
const manifest = [
|
||||||
|
'<dsh_im_files>',
|
||||||
|
JSON.stringify({
|
||||||
|
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
|
||||||
|
files: staged.files,
|
||||||
|
}),
|
||||||
|
'</dsh_im_files>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
|
||||||
|
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
||||||
|
return text ? `${text}\n\n${manifest}` : manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inboundFileUserMessage(error) {
|
||||||
|
return error instanceof InboundFileError ? error.userMessage : null;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,10 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from './image-prompt.mjs';
|
} from './image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
} from './inbound-file.mjs';
|
||||||
import {
|
import {
|
||||||
harnessAnswerForQuestion,
|
harnessAnswerForQuestion,
|
||||||
harnessQuestionText,
|
harnessQuestionText,
|
||||||
|
|
@ -50,6 +54,7 @@ function canClaimInteractionReply(message, pending, senderId) {
|
||||||
return pending.actor === senderId
|
return pending.actor === senderId
|
||||||
&& (message.kind !== 'group' || message.addressed === true)
|
&& (message.kind !== 'group' || message.addressed === true)
|
||||||
&& !hasInboundImages(message)
|
&& !hasInboundImages(message)
|
||||||
|
&& !hasInboundFiles(message)
|
||||||
&& Boolean(cleanText(message.content));
|
&& Boolean(cleanText(message.content));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +176,7 @@ export class TextHarnessBridge {
|
||||||
const key = `${kind}:${conversationId}`;
|
const key = `${kind}:${conversationId}`;
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
const text = cleanText(normalized.content);
|
const text = cleanText(normalized.content);
|
||||||
const commandRunner = isControlCommand(text)
|
const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(text)
|
: (isModelCommand(text)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -194,7 +199,7 @@ export class TextHarnessBridge {
|
||||||
key,
|
key,
|
||||||
actor: senderId,
|
actor: senderId,
|
||||||
messageId,
|
messageId,
|
||||||
text: hasInboundImages(normalized) ? '' : normalized.content,
|
text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
|
||||||
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
||||||
hasPendingQuestion: Boolean(pending),
|
hasPendingQuestion: Boolean(pending),
|
||||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
|
@ -300,6 +305,7 @@ export class TextHarnessBridge {
|
||||||
{
|
{
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasInboundImages(message),
|
hasImages: hasInboundImages(message),
|
||||||
|
hasFiles: hasInboundFiles(message),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -423,16 +429,17 @@ export class TextHarnessBridge {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasImages = hasInboundImages(message);
|
const hasImages = hasInboundImages(message);
|
||||||
if (!text && !hasImages) {
|
const hasFiles = hasInboundFiles(message);
|
||||||
await this.#bot.sendText(target, '目前支持文字和图片消息。');
|
if (!text && !hasImages && !hasFiles) {
|
||||||
|
await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const command = text.toLowerCase();
|
const command = text.toLowerCase();
|
||||||
if (!hasImages && command === '/help') {
|
if (!hasImages && !hasFiles && command === '/help') {
|
||||||
await this.#bot.sendText(target, [
|
await this.#bot.sendText(target, [
|
||||||
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
||||||
'',
|
'',
|
||||||
'直接发送文字或图片即可继续当前会话。',
|
'直接发送文字、图片或文件即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -453,12 +460,12 @@ export class TextHarnessBridge {
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/status') {
|
if (!hasImages && !hasFiles && command === '/status') {
|
||||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceCommand = !hasImages
|
const workspaceCommand = !hasImages && !hasFiles
|
||||||
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
? await runWorkspaceCommand(text, this.#harness, conversationKey)
|
||||||
: null;
|
: null;
|
||||||
if (workspaceCommand) {
|
if (workspaceCommand) {
|
||||||
|
|
@ -467,12 +474,12 @@ export class TextHarnessBridge {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/new') {
|
if (!hasImages && !hasFiles && command === '/new') {
|
||||||
await this.#state.clearSession(conversationKey);
|
await this.#state.clearSession(conversationKey);
|
||||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const compactCommand = !hasImages
|
const compactCommand = !hasImages && !hasFiles
|
||||||
? await runCompactCommand(
|
? await runCompactCommand(
|
||||||
text,
|
text,
|
||||||
this.#harness,
|
this.#harness,
|
||||||
|
|
@ -527,6 +534,7 @@ export class TextHarnessBridge {
|
||||||
requiresMention: message.kind === 'group',
|
requiresMention: message.kind === 'group',
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files: message.files,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
|
const visibleAnswer = !cleanText(answer) && artifacts.length > 0
|
||||||
|
|
@ -600,6 +608,18 @@ export class TextHarnessBridge {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const fileErrorMessage = inboundFileUserMessage(error);
|
||||||
|
if (fileErrorMessage) {
|
||||||
|
try {
|
||||||
|
await this.#bot.sendText(target, fileErrorMessage);
|
||||||
|
} catch (sendError) {
|
||||||
|
this.#logger.error?.(
|
||||||
|
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
|
||||||
|
sendError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
||||||
try {
|
try {
|
||||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||||
|
|
@ -642,7 +662,7 @@ export class TextHarnessBridge {
|
||||||
|
|
||||||
const target = message.replyTarget;
|
const target = message.replyTarget;
|
||||||
const text = cleanText(message.content);
|
const text = cleanText(message.content);
|
||||||
if (!text || hasInboundImages(message)) {
|
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
|
||||||
try {
|
try {
|
||||||
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
await this.#bot.sendText(target, '请用文字回答当前问题。');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
||||||
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
const DEFAULT_BASE_URL = 'https://slack.com/api/';
|
||||||
|
|
@ -232,6 +233,18 @@ export class SlackApi {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fileInfo({ fileId, signal } = {}) {
|
||||||
|
const value = await this.#request('files.info', {
|
||||||
|
tokenKind: 'bot',
|
||||||
|
signal,
|
||||||
|
body: { file: slackId(fileId, 'file id') },
|
||||||
|
});
|
||||||
|
if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
|
||||||
|
throw new Error('Slack files.info returned no file object');
|
||||||
|
}
|
||||||
|
return value.file;
|
||||||
|
}
|
||||||
|
|
||||||
postMessage({ channelId, text, threadTs, signal }) {
|
postMessage({ channelId, text, threadTs, signal }) {
|
||||||
return this.#request('chat.postMessage', {
|
return this.#request('chat.postMessage', {
|
||||||
tokenKind: 'bot',
|
tokenKind: 'bot',
|
||||||
|
|
@ -396,6 +409,14 @@ export class SlackApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile({ url, signal, maxBytes }) {
|
async downloadFile({ url, signal, maxBytes }) {
|
||||||
|
return this.#downloadFile({ url, signal, maxBytes, stream: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFileStream({ url, signal }) {
|
||||||
|
return this.#downloadFile({ url, signal, stream: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async #downloadFile({ url, signal, maxBytes, stream }) {
|
||||||
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
|
||||||
const target = secureSlackFileUrl(url);
|
const target = secureSlackFileUrl(url);
|
||||||
const fetchSlackFile = async (requestUrl, options) => {
|
const fetchSlackFile = async (requestUrl, options) => {
|
||||||
|
|
@ -414,13 +435,15 @@ export class SlackApi {
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
return fetchImageBuffer(target, {
|
const options = {
|
||||||
fetchImpl: fetchSlackFile,
|
fetchImpl: fetchSlackFile,
|
||||||
headers: { authorization: `Bearer ${this.#botToken}` },
|
headers: { authorization: `Bearer ${this.#botToken}` },
|
||||||
signal,
|
signal,
|
||||||
maxBytes,
|
|
||||||
allowedHosts: SLACK_FILE_HOSTS,
|
allowedHosts: SLACK_FILE_HOSTS,
|
||||||
});
|
};
|
||||||
|
return stream
|
||||||
|
? fetchFileStream(target, options)
|
||||||
|
: fetchImageBuffer(target, { ...options, maxBytes });
|
||||||
}
|
}
|
||||||
|
|
||||||
async #request(method, {
|
async #request(method, {
|
||||||
|
|
@ -432,7 +455,7 @@ export class SlackApi {
|
||||||
}) {
|
}) {
|
||||||
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
|
const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
|
||||||
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
|
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
|
||||||
const formEncoded = method === 'files.getUploadURLExternal';
|
const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
response = await this.#fetch(new URL(method, this.#baseUrl), {
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,14 @@ function stripBotMention(value, botUserId) {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slackFileUrl(file) {
|
||||||
|
return typeof file?.url_private_download === 'string' && file.url_private_download
|
||||||
|
? file.url_private_download : file?.url_private;
|
||||||
|
}
|
||||||
|
|
||||||
function slackImageSource(file, loadFile) {
|
function slackImageSource(file, loadFile) {
|
||||||
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
||||||
const url = typeof file?.url_private_download === 'string' && file.url_private_download
|
const url = slackFileUrl(file);
|
||||||
? file.url_private_download : file?.url_private;
|
|
||||||
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
|
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
|
||||||
return {
|
return {
|
||||||
name: typeof file.name === 'string' ? file.name : undefined,
|
name: typeof file.name === 'string' ? file.name : undefined,
|
||||||
|
|
@ -56,9 +60,50 @@ function slackImageSource(file, loadFile) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
|
function slackFileSource(file, loadFile, loadFileInfo) {
|
||||||
throw new Error('Slack file downloader is unavailable');
|
const mediaType = typeof file?.mimetype === 'string' && file.mimetype
|
||||||
} } = {}) {
|
? file.mimetype.toLowerCase() : undefined;
|
||||||
|
const url = slackFileUrl(file);
|
||||||
|
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
|
||||||
|
const requiresInfo = file?.file_access === 'check_file_info'
|
||||||
|
&& typeof file?.id === 'string' && file.id;
|
||||||
|
if ((typeof url !== 'string' || !url) && !requiresInfo) return null;
|
||||||
|
return {
|
||||||
|
name: typeof file?.name === 'string' && file.name
|
||||||
|
? file.name : typeof file?.title === 'string' && file.title
|
||||||
|
? file.title : requiresInfo ? file.id : 'slack-file',
|
||||||
|
...(mediaType ? { mediaType } : {}),
|
||||||
|
size: Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : undefined,
|
||||||
|
load: async ({ signal } = {}) => {
|
||||||
|
if (!requiresInfo) return loadFile(url, { signal });
|
||||||
|
const resolved = await loadFileInfo(file.id, { signal });
|
||||||
|
const resolvedUrl = slackFileUrl(resolved);
|
||||||
|
if (typeof resolvedUrl !== 'string' || !resolvedUrl) {
|
||||||
|
throw new Error('Slack files.info returned no downloadable URL');
|
||||||
|
}
|
||||||
|
const loaded = await loadFile(resolvedUrl, { signal });
|
||||||
|
const name = typeof resolved?.name === 'string' && resolved.name
|
||||||
|
? resolved.name : typeof resolved?.title === 'string' && resolved.title
|
||||||
|
? resolved.title : file.id;
|
||||||
|
const resolvedMediaType = typeof resolved?.mimetype === 'string' && resolved.mimetype
|
||||||
|
? resolved.mimetype.toLowerCase() : undefined;
|
||||||
|
if (Buffer.isBuffer(loaded) || loaded instanceof Uint8Array) {
|
||||||
|
return { data: loaded, name, ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...loaded,
|
||||||
|
name,
|
||||||
|
...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlackEvent(payload, botUserId, {
|
||||||
|
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
|
||||||
|
loadFileStream = loadFile,
|
||||||
|
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
|
||||||
|
} = {}) {
|
||||||
const event = payload?.event;
|
const event = payload?.event;
|
||||||
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
||||||
const direct = event.type === 'message' && event.channel_type === 'im';
|
const direct = event.type === 'message' && event.channel_type === 'im';
|
||||||
|
|
@ -76,6 +121,9 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
|
||||||
images: Array.isArray(event.files)
|
images: Array.isArray(event.files)
|
||||||
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
|
files: Array.isArray(event.files)
|
||||||
|
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
||||||
|
: [],
|
||||||
addressed: direct || mentioned,
|
addressed: direct || mentioned,
|
||||||
replyTarget: {
|
replyTarget: {
|
||||||
channelId: String(event.channel),
|
channelId: String(event.channel),
|
||||||
|
|
@ -436,6 +484,8 @@ export class SlackRuntime {
|
||||||
&& packet.payload.api_app_id !== this.#appId) return;
|
&& packet.payload.api_app_id !== this.#appId) return;
|
||||||
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
|
||||||
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
||||||
|
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
|
||||||
|
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
|
||||||
});
|
});
|
||||||
const bridge = this.#bridge;
|
const bridge = this.#bridge;
|
||||||
if (message && bridge) {
|
if (message && bridge) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { fetchFileStream } from '../shared/file-download.mjs';
|
||||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
||||||
|
|
@ -131,6 +132,25 @@ export class TelegramApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
||||||
|
const url = await this.#downloadUrl(fileId, signal);
|
||||||
|
return fetchImageBuffer(url, {
|
||||||
|
fetchImpl: this.#fetch,
|
||||||
|
signal,
|
||||||
|
maxBytes,
|
||||||
|
allowedHosts: TELEGRAM_FILE_HOSTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFileStream({ fileId, signal } = {}) {
|
||||||
|
const url = await this.#downloadUrl(fileId, signal);
|
||||||
|
return fetchFileStream(url, {
|
||||||
|
fetchImpl: this.#fetch,
|
||||||
|
signal,
|
||||||
|
allowedHosts: TELEGRAM_FILE_HOSTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #downloadUrl(fileId, signal) {
|
||||||
const file = await this.getFile({ fileId, signal });
|
const file = await this.getFile({ fileId, signal });
|
||||||
const filePath = cleanString(file?.file_path);
|
const filePath = cleanString(file?.file_path);
|
||||||
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
||||||
|
|
@ -148,12 +168,7 @@ export class TelegramApi {
|
||||||
}
|
}
|
||||||
const url = new URL(this.#baseUrl);
|
const url = new URL(this.#baseUrl);
|
||||||
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
||||||
return fetchImageBuffer(url, {
|
return url;
|
||||||
fetchImpl: this.#fetch,
|
|
||||||
signal,
|
|
||||||
maxBytes,
|
|
||||||
allowedHosts: TELEGRAM_FILE_HOSTS,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,27 @@ function telegramImageSource(message, loadFile) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
|
function telegramFileSource(message, loadFile) {
|
||||||
throw new Error('Telegram file downloader is unavailable');
|
const file = message?.document;
|
||||||
} }) {
|
if (!file || imageTypeForDocument(file)
|
||||||
|
|| typeof file.file_id !== 'string' || !file.file_id) return null;
|
||||||
|
const mediaType = typeof file.mime_type === 'string' && file.mime_type
|
||||||
|
? file.mime_type.toLowerCase() : undefined;
|
||||||
|
return {
|
||||||
|
name: typeof file.file_name === 'string' && file.file_name
|
||||||
|
? file.file_name : String(file.file_unique_id ?? file.file_id),
|
||||||
|
...(mediaType ? { mediaType } : {}),
|
||||||
|
size: fileSize(file.file_size),
|
||||||
|
load: ({ signal } = {}) => loadFile(file.file_id, { signal }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTelegramUpdate(update, {
|
||||||
|
botId,
|
||||||
|
username,
|
||||||
|
loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
|
||||||
|
loadFileStream = loadFile,
|
||||||
|
}) {
|
||||||
const message = update?.message;
|
const message = update?.message;
|
||||||
const chatId = message?.chat?.id;
|
const chatId = message?.chat?.id;
|
||||||
const senderId = message?.from?.id;
|
const senderId = message?.from?.id;
|
||||||
|
|
@ -117,6 +135,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
||||||
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
||||||
? message.message_thread_id : undefined;
|
? message.message_thread_id : undefined;
|
||||||
const image = telegramImageSource(message, loadFile);
|
const image = telegramImageSource(message, loadFile);
|
||||||
|
const file = telegramFileSource(message, loadFileStream);
|
||||||
return {
|
return {
|
||||||
messageId: String(update.update_id),
|
messageId: String(update.update_id),
|
||||||
senderId: String(senderId),
|
senderId: String(senderId),
|
||||||
|
|
@ -126,6 +145,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
|
||||||
? String(chatId) : `${chatId}:${messageThreadId}`,
|
? String(chatId) : `${chatId}:${messageThreadId}`,
|
||||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||||
images: image ? [image] : [],
|
images: image ? [image] : [],
|
||||||
|
files: file ? [file] : [],
|
||||||
addressed,
|
addressed,
|
||||||
replyTarget: {
|
replyTarget: {
|
||||||
chatId,
|
chatId,
|
||||||
|
|
@ -381,6 +401,7 @@ export class TelegramRuntime {
|
||||||
botId: this.#config.platformId,
|
botId: this.#config.platformId,
|
||||||
username: this.#config.username,
|
username: this.#config.username,
|
||||||
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
||||||
|
loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
|
||||||
});
|
});
|
||||||
if (message && telegramInboundAllowed(message, {
|
if (message && telegramInboundAllowed(message, {
|
||||||
accessMode: this.#accessMode,
|
accessMode: this.#accessMode,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from '../shared/image-prompt.mjs';
|
} from '../shared/image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
} from '../shared/inbound-file.mjs';
|
||||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
materializeOutboundArtifact,
|
materializeOutboundArtifact,
|
||||||
|
|
@ -43,7 +47,7 @@ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'企业微信机器人已连接 DeepSeek Harness。',
|
'企业微信机器人已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
'直接发送文字或图片即可继续当前会话。',
|
'直接发送文字、图片或文件即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -110,6 +114,13 @@ function imageContents(frame) {
|
||||||
.map((item) => item.image);
|
.map((item) => item.image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileContents(frame) {
|
||||||
|
const body = bodyOf(frame);
|
||||||
|
return body.msgtype === 'file' && body.file && typeof body.file === 'object'
|
||||||
|
? [body.file]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
function imageSource(client, image) {
|
function imageSource(client, image) {
|
||||||
const url = nonEmptyString(image?.url);
|
const url = nonEmptyString(image?.url);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
@ -139,10 +150,56 @@ function imageSource(client, image) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileSource(client, file) {
|
||||||
|
const url = nonEmptyString(file?.url);
|
||||||
|
if (!url) return null;
|
||||||
|
const aeskey = nonEmptyString(file?.aeskey) ?? undefined;
|
||||||
|
return {
|
||||||
|
name: nonEmptyString(file?.filename ?? file?.file_name ?? file?.name) ?? 'file',
|
||||||
|
async load({ signal } = {}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (typeof client?.downloadFile !== 'function') {
|
||||||
|
throw new Error('Enterprise WeChat file download is unavailable');
|
||||||
|
}
|
||||||
|
const result = await client.downloadFile(url, aeskey);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const raw = result?.buffer ?? result?.data;
|
||||||
|
if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
|
||||||
|
throw new Error('Enterprise WeChat file download returned no data');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: Buffer.from(raw),
|
||||||
|
...(nonEmptyString(result?.filename) ? { name: result.filename.trim() } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomInboundMessage(frame, client) {
|
export function wecomInboundMessage(frame, client) {
|
||||||
return {
|
return {
|
||||||
content: messageText(frame),
|
content: messageText(frame),
|
||||||
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
|
||||||
|
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchInboundFiles(message, signal) {
|
||||||
|
if (!Array.isArray(message?.files) || message.files.length === 0) return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
files: message.files.map((source) => {
|
||||||
|
const download = source.load({ signal });
|
||||||
|
download.catch(() => undefined);
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
async load({ signal: loadSignal } = {}) {
|
||||||
|
loadSignal?.throwIfAborted();
|
||||||
|
const result = await download;
|
||||||
|
loadSignal?.throwIfAborted();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,7 +457,7 @@ export class WecomHarnessBridge {
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
const commandMessage = wecomInboundMessage(frame, this.#client);
|
const commandMessage = wecomInboundMessage(frame, this.#client);
|
||||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||||
const commandRunner = isControlCommand(commandText)
|
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(commandText)
|
: (isModelCommand(commandText)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -510,6 +567,7 @@ export class WecomHarnessBridge {
|
||||||
preparedMessage = imageQueueFullMessage(inboundMessage);
|
preparedMessage = imageQueueFullMessage(inboundMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
preparedMessage = prefetchInboundFiles(preparedMessage, this.#signal);
|
||||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||||
const current = previous
|
const current = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
@ -543,6 +601,7 @@ export class WecomHarnessBridge {
|
||||||
const result = await runner(message.content, this.#harness, this.#state, key, {
|
const result = await runner(message.content, this.#harness, this.#state, key, {
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasInboundImages(message),
|
hasImages: hasInboundImages(message),
|
||||||
|
hasFiles: hasInboundFiles(message),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -701,34 +760,35 @@ export class WecomHarnessBridge {
|
||||||
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
|
||||||
const text = message.content;
|
const text = message.content;
|
||||||
const hasImages = hasInboundImages(message);
|
const hasImages = hasInboundImages(message);
|
||||||
|
const hasFiles = hasInboundFiles(message);
|
||||||
const key = conversationKey(frame);
|
const key = conversationKey(frame);
|
||||||
let streamId = null;
|
let streamId = null;
|
||||||
let streamStarted = false;
|
let streamStarted = false;
|
||||||
try {
|
try {
|
||||||
if (!text && !hasImages) {
|
if (!text && !hasImages && !hasFiles) {
|
||||||
await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
|
await this.#sendImmediate(frame, chatId, '目前支持文字、图片、文件和语音转写消息。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const command = text.toLowerCase();
|
const command = text.toLowerCase();
|
||||||
if (!hasImages && command === '/help') {
|
if (!hasImages && !hasFiles && command === '/help') {
|
||||||
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
await this.#sendImmediate(frame, chatId, HELP_TEXT);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/status') {
|
if (!hasImages && !hasFiles && command === '/status') {
|
||||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/new') {
|
if (!hasImages && !hasFiles && command === '/new') {
|
||||||
await this.#state.clearSession(key);
|
await this.#state.clearSession(key);
|
||||||
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceCommand = hasImages
|
const workspaceCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runWorkspaceCommand(text, this.#harness, key);
|
: await runWorkspaceCommand(text, this.#harness, key);
|
||||||
if (workspaceCommand) {
|
if (workspaceCommand) {
|
||||||
|
|
@ -738,7 +798,7 @@ export class WecomHarnessBridge {
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const compactCommand = hasImages
|
const compactCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runCompactCommand(
|
: await runCompactCommand(
|
||||||
text,
|
text,
|
||||||
|
|
@ -789,6 +849,7 @@ export class WecomHarnessBridge {
|
||||||
requiresMention: body.chattype === 'group',
|
requiresMention: body.chattype === 'group',
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files: message.files,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -862,7 +923,9 @@ export class WecomHarnessBridge {
|
||||||
if (this.#signal?.aborted) return;
|
if (this.#signal?.aborted) return;
|
||||||
this.#status.lastError = error?.message ?? String(error);
|
this.#status.lastError = error?.message ?? String(error);
|
||||||
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
|
||||||
const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
|
const errorText = inboundFileUserMessage(error)
|
||||||
|
?? imagePromptUserMessage(error)
|
||||||
|
?? '消息处理失败,请稍后重试。';
|
||||||
try {
|
try {
|
||||||
if (streamStarted && streamId) {
|
if (streamStarted && streamId) {
|
||||||
await this.#client.replyStream(frame, streamId, errorText, true);
|
await this.#client.replyStream(frame, streamId, errorText, true);
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,47 @@ export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchWeixinFileCiphertext(url, { fetchImpl, signal }) {
|
||||||
|
const response = await fetchImpl(new URL(url), {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
if (!response?.ok) {
|
||||||
|
await response?.body?.cancel?.().catch?.(() => undefined);
|
||||||
|
throw new WeixinApiError(
|
||||||
|
'file-download-failed',
|
||||||
|
`微信文件下载失败(HTTP ${response?.status ?? 'unknown'})。`,
|
||||||
|
{ status: response?.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert native iLink file items into lazily downloaded, decrypted file references. */
|
||||||
|
export function extractWeixinFiles(message, { fetchImpl = fetch } = {}) {
|
||||||
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
||||||
|
const files = [];
|
||||||
|
for (const item of message?.item_list ?? []) {
|
||||||
|
const fileItem = item?.file_item;
|
||||||
|
if (!fileItem || typeof fileItem !== 'object') continue;
|
||||||
|
const declaredSize = Number(fileItem.len);
|
||||||
|
files.push({
|
||||||
|
name: nonEmptyString(fileItem.file_name) ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
|
||||||
|
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
|
||||||
|
load: async ({ signal } = {}) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const key = parseWeixinImageAesKey(fileItem);
|
||||||
|
const url = weixinImageDownloadUrl(fileItem.media);
|
||||||
|
const ciphertext = await fetchWeixinFileCiphertext(url, { fetchImpl, signal });
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return decryptWeixinImage(ciphertext, key);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
function isWeixinHost(hostname) {
|
function isWeixinHost(hostname) {
|
||||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||||
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
|
||||||
|
|
@ -421,6 +462,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
||||||
return extractWeixinImages(message, { fetchImpl });
|
return extractWeixinImages(message, { fetchImpl });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inboundFiles(message) {
|
||||||
|
return extractWeixinFiles(message, { fetchImpl });
|
||||||
|
},
|
||||||
|
|
||||||
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
|
||||||
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
|
||||||
const response = await requestJson(fetchImpl, {
|
const response = await requestJson(fetchImpl, {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
extractWeixinFiles,
|
||||||
extractWeixinImages,
|
extractWeixinImages,
|
||||||
extractWeixinText,
|
extractWeixinText,
|
||||||
splitWeixinText,
|
splitWeixinText,
|
||||||
|
|
@ -31,6 +32,11 @@ import {
|
||||||
imagePromptUserMessage,
|
imagePromptUserMessage,
|
||||||
promptContentForMessage,
|
promptContentForMessage,
|
||||||
} from '../shared/image-prompt.mjs';
|
} from '../shared/image-prompt.mjs';
|
||||||
|
import {
|
||||||
|
hasInboundFiles,
|
||||||
|
inboundFileUserMessage,
|
||||||
|
prefetchInboundFiles,
|
||||||
|
} from '../shared/inbound-file.mjs';
|
||||||
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
materializeOutboundArtifact,
|
materializeOutboundArtifact,
|
||||||
|
|
@ -49,7 +55,7 @@ const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'微信已连接 DeepSeek Harness。',
|
'微信已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
'直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
|
'直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。',
|
||||||
'/new 开启一个全新会话',
|
'/new 开启一个全新会话',
|
||||||
'/compact 压缩当前会话的较早上下文',
|
'/compact 压缩当前会话的较早上下文',
|
||||||
'/workspace 工作区绝对路径 切换工作区',
|
'/workspace 工作区绝对路径 切换工作区',
|
||||||
|
|
@ -77,15 +83,33 @@ function nonEmptyString(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function weixinInboundMessage(message, api) {
|
||||||
|
return {
|
||||||
|
content: extractWeixinText(message) ?? '',
|
||||||
|
images: typeof api?.inboundImages === 'function'
|
||||||
|
? api.inboundImages(message)
|
||||||
|
: extractWeixinImages(message),
|
||||||
|
files: typeof api?.inboundFiles === 'function'
|
||||||
|
? api.inboundFiles(message)
|
||||||
|
: extractWeixinFiles(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasWeixinImageItems(message) {
|
function hasWeixinImageItems(message) {
|
||||||
return Array.isArray(message?.item_list)
|
return Array.isArray(message?.item_list)
|
||||||
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
|
&& message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasWeixinFileItems(message) {
|
||||||
|
return Array.isArray(message?.item_list)
|
||||||
|
&& message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
|
||||||
|
}
|
||||||
|
|
||||||
function canClaimInteractionReply(message, pending) {
|
function canClaimInteractionReply(message, pending) {
|
||||||
return pending.questions[pending.index]
|
return pending.questions[pending.index]
|
||||||
&& nonEmptyString(message?.from_user_id) === pending.actor
|
&& nonEmptyString(message?.from_user_id) === pending.actor
|
||||||
&& !hasWeixinImageItems(message)
|
&& !hasWeixinImageItems(message)
|
||||||
|
&& !hasWeixinFileItems(message)
|
||||||
&& nonEmptyString(extractWeixinText(message));
|
&& nonEmptyString(extractWeixinText(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +228,7 @@ export class WeixinHarnessBridge {
|
||||||
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
const runId = nonEmptyString(message?.run_id) ?? undefined;
|
||||||
const pending = this.#pendingInteractions.get(key);
|
const pending = this.#pendingInteractions.get(key);
|
||||||
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
|
const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
|
||||||
const commandRunner = isControlCommand(commandText)
|
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
|
||||||
? runControlCommand
|
? runControlCommand
|
||||||
: (isModelCommand(commandText)
|
: (isModelCommand(commandText)
|
||||||
? runModelCommand
|
? runModelCommand
|
||||||
|
|
@ -238,7 +262,9 @@ export class WeixinHarnessBridge {
|
||||||
key,
|
key,
|
||||||
actor: sender,
|
actor: sender,
|
||||||
messageId,
|
messageId,
|
||||||
text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
|
text: hasWeixinImageItems(message) || hasWeixinFileItems(message)
|
||||||
|
? ''
|
||||||
|
: extractWeixinText(message),
|
||||||
addressed: true,
|
addressed: true,
|
||||||
hasPendingQuestion: Boolean(pending),
|
hasPendingQuestion: Boolean(pending),
|
||||||
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
||||||
|
|
@ -290,10 +316,16 @@ export class WeixinHarnessBridge {
|
||||||
releaseMessageId = true,
|
releaseMessageId = true,
|
||||||
alreadyRecorded = false,
|
alreadyRecorded = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const preparedMessage = message.from_user_id === this.#ownerUserId
|
||||||
|
? prefetchInboundFiles(
|
||||||
|
weixinInboundMessage(message, this.#api),
|
||||||
|
{ signal: this.#signal },
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||||
const current = previous
|
const current = previous
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(() => this.#process(message, key, { alreadyRecorded }))
|
.then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
|
||||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||||
|
|
@ -331,6 +363,7 @@ export class WeixinHarnessBridge {
|
||||||
const result = await runner(text, this.#harness, this.#state, key, {
|
const result = await runner(text, this.#harness, this.#state, key, {
|
||||||
signal: this.#signal,
|
signal: this.#signal,
|
||||||
hasImages: hasWeixinImageItems(message),
|
hasImages: hasWeixinImageItems(message),
|
||||||
|
hasFiles: hasWeixinFileItems(message),
|
||||||
pendingInteraction: this.#pendingInteractions.has(key)
|
pendingInteraction: this.#pendingInteractions.has(key)
|
||||||
|| this.#approvals.hasPending(key),
|
|| this.#approvals.hasPending(key),
|
||||||
control: { owner: this, key },
|
control: { owner: this, key },
|
||||||
|
|
@ -348,7 +381,7 @@ export class WeixinHarnessBridge {
|
||||||
this.#status.lastMessageError = null;
|
this.#status.lastMessageError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #process(message, key, { alreadyRecorded = false } = {}) {
|
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
|
||||||
this.#signal?.throwIfAborted();
|
this.#signal?.throwIfAborted();
|
||||||
const messageId = weixinMessageId(message);
|
const messageId = weixinMessageId(message);
|
||||||
const sender = nonEmptyString(message?.from_user_id);
|
const sender = nonEmptyString(message?.from_user_id);
|
||||||
|
|
@ -366,38 +399,36 @@ export class WeixinHarnessBridge {
|
||||||
|
|
||||||
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
|
||||||
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
|
||||||
const text = extractWeixinText(message) ?? '';
|
|
||||||
try {
|
try {
|
||||||
const images = typeof this.#api.inboundImages === 'function'
|
const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
|
||||||
? this.#api.inboundImages(message)
|
const text = promptMessage.content;
|
||||||
: extractWeixinImages(message);
|
|
||||||
const promptMessage = { content: text, images };
|
|
||||||
const hasImages = hasInboundImages(promptMessage);
|
const hasImages = hasInboundImages(promptMessage);
|
||||||
if (!text && !hasImages) {
|
const hasFiles = hasInboundFiles(promptMessage);
|
||||||
await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
|
if (!text && !hasImages && !hasFiles) {
|
||||||
|
await this.#send(sender, '目前支持文字、图片、文件,以及微信已转成文字的语音消息。', contextToken, runId);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = text.trim().toLowerCase();
|
const command = text.trim().toLowerCase();
|
||||||
if (!hasImages && command === '/help') {
|
if (!hasImages && !hasFiles && command === '/help') {
|
||||||
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/status') {
|
if (!hasImages && !hasFiles && command === '/status') {
|
||||||
await this.#harness.ensureRunning({ signal: this.#signal });
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasImages && command === '/new') {
|
if (!hasImages && !hasFiles && command === '/new') {
|
||||||
await this.#state.clearSession(key);
|
await this.#state.clearSession(key);
|
||||||
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspaceCommand = hasImages
|
const workspaceCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runWorkspaceCommand(text, this.#harness, key);
|
: await runWorkspaceCommand(text, this.#harness, key);
|
||||||
if (workspaceCommand) {
|
if (workspaceCommand) {
|
||||||
|
|
@ -407,7 +438,7 @@ export class WeixinHarnessBridge {
|
||||||
await this.#state.markSeen(messageId);
|
await this.#state.markSeen(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const compactCommand = hasImages
|
const compactCommand = hasImages || hasFiles
|
||||||
? null
|
? null
|
||||||
: await runCompactCommand(
|
: await runCompactCommand(
|
||||||
text,
|
text,
|
||||||
|
|
@ -446,6 +477,7 @@ export class WeixinHarnessBridge {
|
||||||
runId,
|
runId,
|
||||||
}),
|
}),
|
||||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||||
|
files: promptMessage.files,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -490,7 +522,9 @@ export class WeixinHarnessBridge {
|
||||||
}
|
}
|
||||||
if (this.#signal?.aborted) return;
|
if (this.#signal?.aborted) return;
|
||||||
this.#status.lastError = error?.message ?? String(error);
|
this.#status.lastError = error?.message ?? String(error);
|
||||||
const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
|
const userMessage = inboundFileUserMessage(error)
|
||||||
|
?? imagePromptUserMessage(error)
|
||||||
|
?? GENERIC_PROCESSING_ERROR;
|
||||||
this.#status.lastMessageError = safeMessageError(error, userMessage);
|
this.#status.lastMessageError = safeMessageError(error, userMessage);
|
||||||
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,39 @@ function whatsappImageSource(message, content, download, { viewOnce = false } =
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function whatsappFileSource(message, content, download) {
|
||||||
|
const media = content?.documentMessage;
|
||||||
|
if (!media) return null;
|
||||||
|
const mediaType = typeof media.mimetype === 'string' && media.mimetype
|
||||||
|
? media.mimetype.toLowerCase() : undefined;
|
||||||
|
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
|
||||||
|
return {
|
||||||
|
name: typeof media.fileName === 'string' && media.fileName
|
||||||
|
? media.fileName : 'whatsapp-file',
|
||||||
|
...(mediaType ? { mediaType } : {}),
|
||||||
|
size: mediaSize(media.fileLength),
|
||||||
|
async load({ signal } = {}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const stream = await download(message, 'stream', { options: { signal } });
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return { stream };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWhatsappMediaDownloader({
|
||||||
|
socket,
|
||||||
|
logger = console,
|
||||||
|
download = downloadMediaMessage,
|
||||||
|
} = {}) {
|
||||||
|
if (typeof download !== 'function') throw new TypeError('WhatsApp media downloader is required');
|
||||||
|
if (typeof socket?.updateMediaMessage !== 'function') return download;
|
||||||
|
return (message, type, options) => download(message, type, options, {
|
||||||
|
logger,
|
||||||
|
reuploadRequest: (candidate) => socket.updateMediaMessage(candidate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeWhatsappMessage(message, accountJid, {
|
export function normalizeWhatsappMessage(message, accountJid, {
|
||||||
download = downloadMediaMessage,
|
download = downloadMediaMessage,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
|
@ -195,6 +228,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
||||||
const replyToSelf = typeof context?.participant === 'string'
|
const replyToSelf = typeof context?.participant === 'string'
|
||||||
&& areJidsSameUser(context.participant, accountJid);
|
&& areJidsSameUser(context.participant, accountJid);
|
||||||
const image = whatsappImageSource(message, content, download, { viewOnce });
|
const image = whatsappImageSource(message, content, download, { viewOnce });
|
||||||
|
const file = whatsappFileSource(message, content, download);
|
||||||
return {
|
return {
|
||||||
messageId: `${remoteJid}:${messageId}`,
|
messageId: `${remoteJid}:${messageId}`,
|
||||||
providerMessageId: messageId,
|
providerMessageId: messageId,
|
||||||
|
|
@ -205,6 +239,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
||||||
conversationId: remoteJid,
|
conversationId: remoteJid,
|
||||||
content: messageText(content),
|
content: messageText(content),
|
||||||
images: image ? [image] : [],
|
images: image ? [image] : [],
|
||||||
|
files: file ? [file] : [],
|
||||||
addressed: !group || mentioned || replyToSelf,
|
addressed: !group || mentioned || replyToSelf,
|
||||||
selfChat,
|
selfChat,
|
||||||
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
||||||
|
|
@ -495,8 +530,13 @@ export class WhatsappRuntime {
|
||||||
new Error('WhatsApp linked-device session must be scanned again'),
|
new Error('WhatsApp linked-device session must be scanned again'),
|
||||||
{ code: 'relink-required' },
|
{ code: 'relink-required' },
|
||||||
)),
|
)),
|
||||||
onMessage: async (raw) => {
|
onMessage: async (raw, context) => {
|
||||||
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
|
const message = normalizeWhatsappMessage(raw, this.#config.accountJid, {
|
||||||
|
download: createWhatsappMediaDownloader({
|
||||||
|
socket: context?.socket,
|
||||||
|
logger: this.#logger,
|
||||||
|
}),
|
||||||
|
});
|
||||||
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
||||||
this.#status.lastCheckedAt = Date.now();
|
this.#status.lastCheckedAt = Date.now();
|
||||||
if (!whatsappInboundAllowed(message, {
|
if (!whatsappInboundAllowed(message, {
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export async function createWhatsappWebSession({
|
||||||
const timestamp = messageTimestampMs(message?.messageTimestamp);
|
const timestamp = messageTimestampMs(message?.messageTimestamp);
|
||||||
if (timestamp === null || timestamp < sessionStartedAt - APPEND_RECENT_GRACE_MS) continue;
|
if (timestamp === null || timestamp < sessionStartedAt - APPEND_RECENT_GRACE_MS) continue;
|
||||||
}
|
}
|
||||||
Promise.resolve(onMessage(message)).catch(() => {
|
Promise.resolve(onMessage(message, { socket: nextSocket })).catch(() => {
|
||||||
logger.error?.('[dsh-im:whatsapp] failed to process an inbound WhatsApp message');
|
logger.error?.('[dsh-im:whatsapp] failed to process an inbound WhatsApp message');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,38 @@ test('DingTalk image downloads exchange downloadCode with the callback robotCode
|
||||||
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
|
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DingTalk downloads ordinary files through the native downloadCode exchange without image limits', async () => {
|
||||||
|
const fileBytes = Buffer.from('ordinary-dingtalk-file');
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
const href = url.toString();
|
||||||
|
calls.push({ url: href, options });
|
||||||
|
if (href === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
if (href === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
|
||||||
|
return jsonResponse({ downloadUrl: 'https://download.example.test/native-file' });
|
||||||
|
}
|
||||||
|
return new Response(fileBytes);
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl });
|
||||||
|
|
||||||
|
assert.deepEqual(await api.downloadFile({
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
downloadCode: 'ordinary-file-code',
|
||||||
|
}), fileBytes);
|
||||||
|
assert.deepEqual(JSON.parse(calls[1].options.body), {
|
||||||
|
downloadCode: 'ordinary-file-code',
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
});
|
||||||
|
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
|
||||||
|
assert.equal(calls[2].options.method, 'GET');
|
||||||
|
assert.equal(calls[2].options.redirect, 'follow');
|
||||||
|
assert.equal(calls[2].options.headers, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('DingTalk upgrades its HTTP temporary image URL to HTTPS without changing the signed request', async () => {
|
test('DingTalk upgrades its HTTP temporary image URL to HTTPS without changing the signed request', async () => {
|
||||||
const imageBytes = Buffer.from([0xff, 0xd8, 0xff, 0x01]);
|
const imageBytes = Buffer.from([0xff, 0xd8, 0xff, 0x01]);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createDingtalkBridgeStatus,
|
createDingtalkBridgeStatus,
|
||||||
|
dingtalkInboundMessage,
|
||||||
DingtalkHarnessBridge,
|
DingtalkHarnessBridge,
|
||||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||||
|
|
@ -122,6 +123,141 @@ async function committedArtifact(t, fileName, content) {
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('DingTalk normalizes a native file callback into one lazy ordinary file', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const bytes = Buffer.from('dingtalk-native-file');
|
||||||
|
const inbound = dingtalkInboundMessage({
|
||||||
|
msgtype: 'file',
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
content: JSON.stringify({
|
||||||
|
spaceId: 'space-one',
|
||||||
|
fileId: 'file-one',
|
||||||
|
fileName: '钉钉报告.zip',
|
||||||
|
downloadCode: 'opaque-file-code',
|
||||||
|
}),
|
||||||
|
}, {
|
||||||
|
api: {
|
||||||
|
downloadFile: async (request) => {
|
||||||
|
calls.push(request);
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(inbound.content, '');
|
||||||
|
assert.deepEqual(inbound.images, []);
|
||||||
|
assert.equal(inbound.files.length, 1);
|
||||||
|
assert.equal(inbound.files[0].name, '钉钉报告.zip');
|
||||||
|
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||||
|
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
downloadCode: 'opaque-file-code',
|
||||||
|
signal: undefined,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DingTalk bridge hands a native file source to the current Harness turn', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:staff-approved', 'session-native-file');
|
||||||
|
const bytes = Buffer.from('dingtalk-bridge-file');
|
||||||
|
const downloads = [];
|
||||||
|
const prompts = [];
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: {
|
||||||
|
downloadFile: async (request) => {
|
||||||
|
downloads.push(request);
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
sendText: async (request) => sent.push(request.text),
|
||||||
|
},
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, prompt, options) => {
|
||||||
|
prompts.push({
|
||||||
|
sessionId,
|
||||||
|
prompt,
|
||||||
|
name: options.files[0].name,
|
||||||
|
bytes: await options.files[0].load({ signal: options.signal }),
|
||||||
|
});
|
||||||
|
return '文件已收到';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('dingtalk-native-file', '', {
|
||||||
|
msgtype: 'file',
|
||||||
|
text: undefined,
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
content: {
|
||||||
|
fileName: '钉钉报告.pdf',
|
||||||
|
downloadCode: 'native-file-code',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(downloads.length, 1);
|
||||||
|
assert.equal(downloads[0].downloadCode, 'native-file-code');
|
||||||
|
assert.deepEqual(prompts, [{
|
||||||
|
sessionId: 'session-native-file',
|
||||||
|
prompt: '',
|
||||||
|
name: '钉钉报告.pdf',
|
||||||
|
bytes,
|
||||||
|
}]);
|
||||||
|
assert.equal(sent.at(-1), '文件已收到');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DingTalk starts a native-file download before an earlier queued turn finishes', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:staff-approved', 'session-prefetch-file');
|
||||||
|
const firstTurn = deferred();
|
||||||
|
const bytes = Buffer.from('dingtalk-prefetched-file');
|
||||||
|
let asks = 0;
|
||||||
|
let downloads = 0;
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: {
|
||||||
|
downloadFile: async () => {
|
||||||
|
downloads += 1;
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
sendText: async () => {},
|
||||||
|
},
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (_sessionId, _prompt, options) => {
|
||||||
|
asks += 1;
|
||||||
|
if (asks === 1) return firstTurn.promise;
|
||||||
|
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||||
|
return '第二条完成';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = bridge.accept(message('dingtalk-prefetch-first', '先等待'));
|
||||||
|
await eventually(() => asks === 1);
|
||||||
|
const second = bridge.accept(message('dingtalk-prefetch-second', '', {
|
||||||
|
msgtype: 'file',
|
||||||
|
text: undefined,
|
||||||
|
robotCode: 'robot-from-callback',
|
||||||
|
content: { fileName: 'queued.bin', downloadCode: 'queued-file-code' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await eventually(() => downloads === 1, 'queued DingTalk file did not start downloading');
|
||||||
|
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||||
|
firstTurn.resolve('第一条完成');
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
||||||
const privateFixture = stateFixture();
|
const privateFixture = stateFixture();
|
||||||
const privateSent = [];
|
const privateSent = [];
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
||||||
assert.equal(unmentionedReply.addressed, false);
|
assert.equal(unmentionedReply.addressed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Discord exposes image attachments through bounded CDN loaders', async () => {
|
test('Discord keeps image attachments in images and exposes ordinary attachments as files', async () => {
|
||||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const message = normalizeDiscordMessage({
|
const message = normalizeDiscordMessage({
|
||||||
|
|
@ -411,6 +411,19 @@ test('Discord exposes image attachments through bounded CDN loaders', async () =
|
||||||
assert.deepEqual(await message.images[0].load({ maxBytes: 100 }), png);
|
assert.deepEqual(await message.images[0].load({ maxBytes: 100 }), png);
|
||||||
assert.equal(calls[0].url.hostname, 'cdn.discordapp.com');
|
assert.equal(calls[0].url.hostname, 'cdn.discordapp.com');
|
||||||
assert.equal(calls[0].options.redirect, 'manual');
|
assert.equal(calls[0].options.redirect, 'manual');
|
||||||
|
assert.equal(message.files.length, 1);
|
||||||
|
assert.deepEqual({
|
||||||
|
name: message.files[0].name,
|
||||||
|
mediaType: message.files[0].mediaType,
|
||||||
|
size: message.files[0].size,
|
||||||
|
}, { name: 'notes.txt', mediaType: 'text/plain', size: 4 });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loadedFile = await message.files[0].load({ signal: controller.signal });
|
||||||
|
const fileChunks = [];
|
||||||
|
for await (const chunk of loadedFile.stream) fileChunks.push(Buffer.from(chunk));
|
||||||
|
assert.deepEqual(Buffer.concat(fileChunks), png);
|
||||||
|
assert.equal(calls[1].url.pathname.endsWith('/notes.txt'), true);
|
||||||
|
assert.equal(calls[1].options.signal, controller.signal);
|
||||||
|
|
||||||
const unsafe = normalizeDiscordMessage({
|
const unsafe = normalizeDiscordMessage({
|
||||||
id: '111111111111111121',
|
id: '111111111111111121',
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,63 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
|
||||||
assert.deepEqual(sent, ['看到了一张图片']);
|
assert.deepEqual(sent, ['看到了一张图片']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
|
||||||
|
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
|
||||||
|
const bytes = Buffer.from('feishu-native-file');
|
||||||
|
const downloads = [];
|
||||||
|
const asked = [];
|
||||||
|
const sent = [];
|
||||||
|
const client = {
|
||||||
|
im: { v1: {
|
||||||
|
messageResource: { get: async (request) => {
|
||||||
|
downloads.push(request);
|
||||||
|
return { getReadableStream: () => Readable.from([bytes]) };
|
||||||
|
} },
|
||||||
|
message: { create: async (request) => {
|
||||||
|
sent.push(JSON.parse(request.data.content).text);
|
||||||
|
return { code: 0, data: { message_id: 'om_file_reply' } };
|
||||||
|
} },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client,
|
||||||
|
channel: {},
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, prompt, options) => {
|
||||||
|
asked.push({
|
||||||
|
sessionId,
|
||||||
|
prompt,
|
||||||
|
name: options.files[0].name,
|
||||||
|
bytes: await options.files[0].load({ signal: options.signal }),
|
||||||
|
});
|
||||||
|
return '文件已收到';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status: bridgeStatus(),
|
||||||
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(event('om_file_input', '', {
|
||||||
|
message_type: 'file',
|
||||||
|
content: JSON.stringify({ file_key: 'file_input', file_name: '飞书报告.pdf' }),
|
||||||
|
}));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.deepEqual(downloads, [{
|
||||||
|
path: { message_id: 'om_file_input', file_key: 'file_input' },
|
||||||
|
params: { type: 'file' },
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(asked, [{
|
||||||
|
sessionId: 'session-file',
|
||||||
|
prompt: '',
|
||||||
|
name: '飞书报告.pdf',
|
||||||
|
bytes,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['文件已收到']);
|
||||||
|
});
|
||||||
|
|
||||||
test('bridge tells users to grant im:message:readonly when Feishu rejects image access', async () => {
|
test('bridge tells users to grant im:message:readonly when Feishu rejects image access', async () => {
|
||||||
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
|
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
|
||||||
const sent = [];
|
const sent = [];
|
||||||
|
|
|
||||||
|
|
@ -644,6 +644,162 @@ test('HarnessClient asks do not control file-return tool availability', async ()
|
||||||
}), 'done');
|
}), 'done');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('HarnessClient stages inbound files, appends a neutral manifest, and cleans after turn end', async () => {
|
||||||
|
const inboundSources = [{ name: '用户报告.bin', load: async () => Buffer.from('bytes') }];
|
||||||
|
const ingressCalls = [];
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
let promptPayload;
|
||||||
|
let promptRpcId;
|
||||||
|
let prompted = false;
|
||||||
|
const stagedFiles = [{
|
||||||
|
name: '用户报告.bin',
|
||||||
|
path: '.dsh-im/inbound/turn-abc/01-用户报告.bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
}];
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/must-not-select-the-session-cwd',
|
||||||
|
fileIngressExecutor: async (request) => {
|
||||||
|
ingressCalls.push(request);
|
||||||
|
return {
|
||||||
|
files: stagedFiles,
|
||||||
|
async cleanup() { cleanupCalls += 1; },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.ensureRunning = async () => undefined;
|
||||||
|
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||||
|
if (method === 'session.history' && !prompted) return { events: [] };
|
||||||
|
if (method === 'session.list') {
|
||||||
|
return { items: [{ sessionId: 'session-inbound-files', cwd: '/tmp/exact-session-cwd' }] };
|
||||||
|
}
|
||||||
|
if (method === 'session.prompt') {
|
||||||
|
prompted = true;
|
||||||
|
promptPayload = payload;
|
||||||
|
promptRpcId = options.rpcId;
|
||||||
|
assert.equal(cleanupCalls, 0, 'files remain available while the prompt is running');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
events: [
|
||||||
|
{ event: { type: 'turn/start', seq: 1, data: { turn: 3 } } },
|
||||||
|
{
|
||||||
|
event: {
|
||||||
|
type: 'user/message',
|
||||||
|
seq: 2,
|
||||||
|
data: { turn: 3, source: { rpcId: promptRpcId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: {
|
||||||
|
type: 'assistant/message',
|
||||||
|
seq: 3,
|
||||||
|
data: {
|
||||||
|
turn: 3,
|
||||||
|
step: 1,
|
||||||
|
message: { content: [{ type: 'text', text: '已读取附件。' }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ event: { type: 'turn/end', seq: 4, data: { turn: 3, reason: { kind: 'completed' } } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await client.ask('session-inbound-files', '请查看附件', {
|
||||||
|
files: inboundSources,
|
||||||
|
}), '已读取附件。');
|
||||||
|
|
||||||
|
assert.equal(ingressCalls.length, 1);
|
||||||
|
assert.equal(ingressCalls[0].sessionId, 'session-inbound-files');
|
||||||
|
assert.equal(ingressCalls[0].workspace, '/tmp/exact-session-cwd');
|
||||||
|
assert.deepEqual(ingressCalls[0].files, inboundSources);
|
||||||
|
assert.equal(ingressCalls[0].files[0], inboundSources[0]);
|
||||||
|
assert.equal(ingressCalls[0].signal, undefined);
|
||||||
|
assert.equal(promptPayload.sessionId, 'session-inbound-files');
|
||||||
|
assert.equal(promptPayload.mode, 'queue');
|
||||||
|
assert.equal(promptPayload.content.length, 1);
|
||||||
|
assert.equal(promptPayload.content[0].type, 'text');
|
||||||
|
const promptText = promptPayload.content[0].text;
|
||||||
|
assert.match(promptText, /^请查看附件\n\n<dsh_im_files>\n/);
|
||||||
|
assert.match(promptText, /\n<\/dsh_im_files>$/);
|
||||||
|
const manifest = JSON.parse(promptText.match(/<dsh_im_files>\n(.+)\n<\/dsh_im_files>$/s)[1]);
|
||||||
|
assert.deepEqual(manifest, {
|
||||||
|
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
|
||||||
|
files: stagedFiles,
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(promptText, /summari[sz]e|解析|总结|处理这些文件/i);
|
||||||
|
assert.equal(cleanupCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HarnessClient cleans staged inbound files when session.prompt rejects them', async () => {
|
||||||
|
const promptFailure = new Error('Harness rejected prompt');
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
let prompted = false;
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/default-workspace',
|
||||||
|
fileIngressExecutor: async () => ({
|
||||||
|
files: [{ name: 'rejected.dat', path: '.dsh-im/inbound/turn-rejected/01-rejected.dat' }],
|
||||||
|
async cleanup() { cleanupCalls += 1; },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
client.ensureRunning = async () => undefined;
|
||||||
|
client.rpc = async (method) => {
|
||||||
|
if (method === 'session.history' && !prompted) return { events: [] };
|
||||||
|
if (method === 'session.list') {
|
||||||
|
return { items: [{ sessionId: 'session-rejected-file', cwd: '/tmp/rejected-cwd' }] };
|
||||||
|
}
|
||||||
|
assert.equal(method, 'session.prompt');
|
||||||
|
prompted = true;
|
||||||
|
throw promptFailure;
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
client.ask('session-rejected-file', '', {
|
||||||
|
files: [{ name: 'rejected.dat', data: Buffer.from('bytes') }],
|
||||||
|
}),
|
||||||
|
(error) => error === promptFailure,
|
||||||
|
);
|
||||||
|
assert.equal(cleanupCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HarnessClient retains staged files when an accepted turn outcome is unknown', async () => {
|
||||||
|
const uncertainFailure = new Error('history transport failed after prompt acceptance');
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
let prompted = false;
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/default-workspace',
|
||||||
|
fileIngressExecutor: async () => ({
|
||||||
|
files: [{ name: 'uncertain.dat', path: '.dsh-im/inbound/turn-unknown/01-uncertain.dat' }],
|
||||||
|
async cleanup() { cleanupCalls += 1; },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
client.ensureRunning = async () => undefined;
|
||||||
|
client.rpc = async (method) => {
|
||||||
|
if (method === 'session.history' && !prompted) return { events: [] };
|
||||||
|
if (method === 'session.list') {
|
||||||
|
return { items: [{ sessionId: 'session-uncertain-file', cwd: '/tmp/uncertain-cwd' }] };
|
||||||
|
}
|
||||||
|
if (method === 'session.prompt') {
|
||||||
|
prompted = true;
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
assert.equal(method, 'session.history');
|
||||||
|
throw uncertainFailure;
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
client.ask('session-uncertain-file', 'use the attached file', {
|
||||||
|
files: [{ name: 'uncertain.dat', data: Buffer.from('bytes') }],
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
}),
|
||||||
|
(error) => error === uncertainFailure,
|
||||||
|
);
|
||||||
|
assert.equal(cleanupCalls, 0, 'uncertain accepted turns may still be reading the staged path');
|
||||||
|
});
|
||||||
|
|
||||||
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
|
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
|
||||||
outboundArtifactRegistry.clear();
|
outboundArtifactRegistry.clear();
|
||||||
t.after(() => outboundArtifactRegistry.clear());
|
t.after(() => outboundArtifactRegistry.clear());
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,36 @@ test('extractInboundMessage preserves visible Feishu post text and every embedde
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('extractInboundMessage exposes a native Feishu file as a lazy unbounded resource download', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const bytes = Buffer.from('ordinary-file-payload');
|
||||||
|
const event = {
|
||||||
|
message: {
|
||||||
|
message_id: 'om_file',
|
||||||
|
message_type: 'file',
|
||||||
|
content: JSON.stringify({ file_key: 'file_test', file_name: 'report.bin' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const client = { im: { v1: { messageResource: { get: async (request) => {
|
||||||
|
calls.push(request);
|
||||||
|
return {
|
||||||
|
getReadableStream: () => Readable.from([bytes.subarray(0, 4), bytes.subarray(4)]),
|
||||||
|
};
|
||||||
|
} } } } };
|
||||||
|
|
||||||
|
const message = extractInboundMessage(event, client);
|
||||||
|
assert.equal(message.content, '');
|
||||||
|
assert.deepEqual(message.images, []);
|
||||||
|
assert.equal(message.files.length, 1);
|
||||||
|
assert.equal(message.files[0].name, 'report.bin');
|
||||||
|
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||||
|
assert.deepEqual(await message.files[0].load({}), bytes);
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
path: { message_id: 'om_file', file_key: 'file_test' },
|
||||||
|
params: { type: 'file' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
test('Feishu image loading rejects declared or streamed data above the caller limit', async () => {
|
test('Feishu image loading rejects declared or streamed data above the caller limit', async () => {
|
||||||
for (const resource of [
|
for (const resource of [
|
||||||
{
|
{
|
||||||
|
|
@ -182,10 +212,10 @@ test('Feishu image loading leaves unrelated provider failures on the generic pat
|
||||||
test('malformed Feishu image content does not create a downloadable image reference', () => {
|
test('malformed Feishu image content does not create a downloadable image reference', () => {
|
||||||
assert.deepEqual(extractInboundMessage({
|
assert.deepEqual(extractInboundMessage({
|
||||||
message: { message_type: 'image', content: '{not-json' },
|
message: { message_type: 'image', content: '{not-json' },
|
||||||
}, {}), { content: '', images: [] });
|
}, {}), { content: '', images: [], files: [] });
|
||||||
assert.deepEqual(extractInboundMessage({
|
assert.deepEqual(extractInboundMessage({
|
||||||
message: { message_type: 'post', content: '{not-json' },
|
message: { message_type: 'post', content: '{not-json' },
|
||||||
}, {}), { content: '', images: [] });
|
}, {}), { content: '', images: [], files: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('conversationKey isolates p2p users and groups', () => {
|
test('conversationKey isolates p2p users and groups', () => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
import { qqInboundMessage, QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
OUTBOUND_ARTIFACT_TOOL,
|
OUTBOUND_ARTIFACT_TOOL,
|
||||||
|
|
@ -96,6 +96,141 @@ const PNG_BYTES = Buffer.from([
|
||||||
0x00, 0x00, 0x00, 0x00,
|
0x00, 0x00, 0x00, 0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
test('QQ normalizes protocol-relative ordinary-file URLs and lets the CDN redirect', async () => {
|
||||||
|
const bytes = Buffer.from('qq-native-file');
|
||||||
|
const calls = [];
|
||||||
|
const inbound = qqInboundMessage(message({
|
||||||
|
content: '请读取附件',
|
||||||
|
attachments: [{
|
||||||
|
content_type: 'application/pdf',
|
||||||
|
filename: 'QQ 报告.pdf',
|
||||||
|
size: bytes.length,
|
||||||
|
url: '//provider-issued-download.example/native-file',
|
||||||
|
}],
|
||||||
|
}), {
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url: url.toString(), init });
|
||||||
|
return new Response(bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(inbound.content, '请读取附件');
|
||||||
|
assert.deepEqual(inbound.images, []);
|
||||||
|
assert.equal(inbound.files.length, 1);
|
||||||
|
assert.equal(inbound.files[0].name, 'QQ 报告.pdf');
|
||||||
|
assert.equal(inbound.files[0].mediaType, 'application/pdf');
|
||||||
|
assert.equal(inbound.files[0].size, bytes.length);
|
||||||
|
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||||
|
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
url: 'https://provider-issued-download.example/native-file',
|
||||||
|
init: { method: 'GET', signal: undefined, redirect: 'follow' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QQ does not duplicate image attachments in ordinary files', () => {
|
||||||
|
const inbound = qqInboundMessage(message({
|
||||||
|
attachments: [{
|
||||||
|
content_type: 'file',
|
||||||
|
filename: 'diagram.PNG',
|
||||||
|
url: 'https://multimedia.nt.qq.com.cn/download/opaque',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
assert.equal(inbound.images.length, 1);
|
||||||
|
assert.deepEqual(inbound.files, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QQ bridge hands a native non-image attachment to the current Harness turn', async () => {
|
||||||
|
const fixture = stateFixture([['c2c:owner-openid', 'session-native-file']]);
|
||||||
|
const bytes = Buffer.from('qq-bridge-file');
|
||||||
|
const downloads = [];
|
||||||
|
const prompts = [];
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new QqHarnessBridge({
|
||||||
|
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||||
|
ownerUserOpenid: 'owner-openid',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, prompt, options) => {
|
||||||
|
prompts.push({
|
||||||
|
sessionId,
|
||||||
|
prompt,
|
||||||
|
name: options.files[0].name,
|
||||||
|
bytes: await options.files[0].load({ signal: options.signal }),
|
||||||
|
});
|
||||||
|
return '文件已收到';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
downloads.push({ url: url.toString(), init });
|
||||||
|
return new Response(bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message({
|
||||||
|
messageId: 'qq-native-file',
|
||||||
|
content: '',
|
||||||
|
attachments: [{
|
||||||
|
content_type: 'application/octet-stream',
|
||||||
|
filename: 'QQ报告.bin',
|
||||||
|
url: 'https://provider-issued-download.example/qq-file',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(downloads.length, 1);
|
||||||
|
assert.deepEqual(prompts, [{
|
||||||
|
sessionId: 'session-native-file',
|
||||||
|
prompt: '',
|
||||||
|
name: 'QQ报告.bin',
|
||||||
|
bytes,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['文件已收到']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QQ starts an ordinary-file download before an earlier queued turn finishes', async () => {
|
||||||
|
const fixture = stateFixture([['c2c:owner-openid', 'session-prefetch-file']]);
|
||||||
|
const firstTurn = deferred();
|
||||||
|
const bytes = Buffer.from('qq-prefetched-file');
|
||||||
|
let asks = 0;
|
||||||
|
let downloads = 0;
|
||||||
|
const bridge = new QqHarnessBridge({
|
||||||
|
bot: { sendText: async () => {} },
|
||||||
|
ownerUserOpenid: 'owner-openid',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (_sessionId, _prompt, options) => {
|
||||||
|
asks += 1;
|
||||||
|
if (asks === 1) return firstTurn.promise;
|
||||||
|
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||||
|
return '第二条完成';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
fetchImpl: async () => {
|
||||||
|
downloads += 1;
|
||||||
|
return new Response(bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = bridge.accept(message({ messageId: 'qq-prefetch-first', content: '先等待' }));
|
||||||
|
await eventually(() => asks === 1);
|
||||||
|
const second = bridge.accept(message({
|
||||||
|
messageId: 'qq-prefetch-second',
|
||||||
|
content: '',
|
||||||
|
attachments: [{
|
||||||
|
content_type: 'application/octet-stream',
|
||||||
|
filename: 'queued.bin',
|
||||||
|
url: '//provider-issued-download.example/queued-file',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await eventually(() => downloads === 1, 'queued QQ file did not start downloading');
|
||||||
|
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||||
|
firstTurn.resolve('第一条完成');
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
test('QQ sends image-only attachments to Harness and accepts the SDK file MIME fallback', async () => {
|
test('QQ sends image-only attachments to Harness and accepts the SDK file MIME fallback', async () => {
|
||||||
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
|
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
|
||||||
const prompts = [];
|
const prompts = [];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import test from 'node:test';
|
||||||
|
|
||||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||||
|
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
|
||||||
import {
|
import {
|
||||||
OUTBOUND_ARTIFACT_TOOL,
|
OUTBOUND_ARTIFACT_TOOL,
|
||||||
OutboundArtifactRegistry,
|
OutboundArtifactRegistry,
|
||||||
|
|
@ -214,6 +215,162 @@ test('all four shared text channels execute /compact outside the model prompt pa
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shared text bridge passes a file-only message through askOptions.files', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const source = Object.freeze({
|
||||||
|
name: 'report.bin',
|
||||||
|
load: async () => Buffer.from([0x00, 0xff]),
|
||||||
|
});
|
||||||
|
const asks = [];
|
||||||
|
const sent = [];
|
||||||
|
const bridge = createBridge({
|
||||||
|
state: fixture.state,
|
||||||
|
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||||
|
harness: {
|
||||||
|
createSession: async () => 'session-file-only-inbound',
|
||||||
|
ask: async (sessionId, text, options) => {
|
||||||
|
asks.push({ sessionId, text, files: options.files });
|
||||||
|
return '文件已交给 Harness。';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('inbound-file-only', '', { files: [source] }));
|
||||||
|
|
||||||
|
assert.deepEqual(asks, [{
|
||||||
|
sessionId: 'session-file-only-inbound',
|
||||||
|
text: '',
|
||||||
|
files: [source],
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['文件已交给 Harness。']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared text bridge keeps caption and native files in one Harness ask', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sources = [
|
||||||
|
{ name: 'one.txt', data: Buffer.from('one') },
|
||||||
|
{ name: 'two.zip', load: async () => Buffer.from('two') },
|
||||||
|
];
|
||||||
|
const asks = [];
|
||||||
|
const bridge = createBridge({
|
||||||
|
state: fixture.state,
|
||||||
|
bot: { sendText: async () => undefined },
|
||||||
|
harness: {
|
||||||
|
createSession: async () => 'session-text-and-files',
|
||||||
|
ask: async (sessionId, text, options) => {
|
||||||
|
asks.push({ sessionId, text, files: options.files });
|
||||||
|
return '完成';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('inbound-text-and-files', '请检查这两个文件', { files: sources }));
|
||||||
|
|
||||||
|
assert.deepEqual(asks, [{
|
||||||
|
sessionId: 'session-text-and-files',
|
||||||
|
text: '请检查这两个文件',
|
||||||
|
files: sources,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a command-looking file caption is an ordinary Harness prompt, not a bridge command', async () => {
|
||||||
|
const fixture = stateFixture({ 'direct:chat-a': 'session-existing' });
|
||||||
|
const source = { name: 'new.txt', data: Buffer.from('not a command') };
|
||||||
|
const asks = [];
|
||||||
|
const sent = [];
|
||||||
|
const bridge = createBridge({
|
||||||
|
state: fixture.state,
|
||||||
|
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, text, options) => {
|
||||||
|
asks.push({ sessionId, text, files: options.files });
|
||||||
|
return '附件消息已处理';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('file-caption-command', '/new', { files: [source] }));
|
||||||
|
|
||||||
|
assert.equal(fixture.sessions.get('direct:chat-a'), 'session-existing');
|
||||||
|
assert.deepEqual(asks, [{
|
||||||
|
sessionId: 'session-existing',
|
||||||
|
text: '/new',
|
||||||
|
files: [source],
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['附件消息已处理']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an inbound file cannot claim a pending Harness interaction answer', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const questionAnswered = deferred();
|
||||||
|
let interactionResponses = 0;
|
||||||
|
const asks = [];
|
||||||
|
const bridge = createBridge({
|
||||||
|
state: fixture.state,
|
||||||
|
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||||
|
harness: {
|
||||||
|
createSession: async () => 'session-file-during-question',
|
||||||
|
ask: async (sessionId, text, options) => {
|
||||||
|
asks.push({ sessionId, text, files: options.files });
|
||||||
|
await options.onInteraction(questionInteraction({
|
||||||
|
sessionId,
|
||||||
|
questions: [{ id: 'confirm', question: '是否继续?' }],
|
||||||
|
respond: async (result) => {
|
||||||
|
interactionResponses += 1;
|
||||||
|
questionAnswered.resolve(result);
|
||||||
|
return { accepted: true };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await questionAnswered.promise;
|
||||||
|
return '继续执行';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const processing = bridge.accept(message('question-with-file-start', '先询问我'));
|
||||||
|
await eventually(() => sent.some((text) => text.includes('是否继续?')));
|
||||||
|
await bridge.accept(message('question-file-attempt', 'yes', {
|
||||||
|
files: [{ name: 'answer.txt', data: Buffer.from('yes') }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(interactionResponses, 0);
|
||||||
|
assert.equal(asks.length, 1, 'the attachment must not become a sibling ask while interaction is open');
|
||||||
|
assert.equal(sent.at(-1), '请用文字回答当前问题。');
|
||||||
|
|
||||||
|
await bridge.accept(message('question-text-answer', 'yes'));
|
||||||
|
await processing;
|
||||||
|
assert.equal(interactionResponses, 1);
|
||||||
|
assert.equal(sent.at(-1), '继续执行');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared text bridge reports a safe native-file download failure', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const bridge = createBridge({
|
||||||
|
state: fixture.state,
|
||||||
|
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||||
|
harness: {
|
||||||
|
createSession: async () => 'session-file-download-failure',
|
||||||
|
ask: async () => {
|
||||||
|
throw new InboundFileError(
|
||||||
|
'inbound-file-download-failed',
|
||||||
|
'private channel URL https://secret.example/token failed',
|
||||||
|
'文件下载失败,请重新发送后再试。',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('file-download-failure', '', {
|
||||||
|
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['文件下载失败,请重新发送后再试。']);
|
||||||
|
assert.doesNotMatch(sent[0], /secret|token|https:/i);
|
||||||
|
});
|
||||||
|
|
||||||
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
||||||
for (const [name, Bridge] of [
|
for (const [name, Bridge] of [
|
||||||
['slack', SlackHarnessBridge],
|
['slack', SlackHarnessBridge],
|
||||||
|
|
|
||||||
|
|
@ -419,10 +419,17 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
|
||||||
}), png);
|
}), png);
|
||||||
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||||
assert.equal(calls[1].url.hostname, 'files.slack.com');
|
assert.equal(calls[1].url.hostname, 'files.slack.com');
|
||||||
|
const streamed = await api.downloadFileStream({
|
||||||
|
url: 'https://files.slack.com/files-pri/T123-F126/download/notes.txt',
|
||||||
|
});
|
||||||
|
const streamedChunks = [];
|
||||||
|
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
|
||||||
|
assert.deepEqual(Buffer.concat(streamedChunks), png);
|
||||||
|
assert.equal(calls[2].options.signal, undefined);
|
||||||
await assert.rejects(() => api.downloadFile({
|
await assert.rejects(() => api.downloadFile({
|
||||||
url: 'https://example.com/internal.png', maxBytes: 100,
|
url: 'https://example.com/internal.png', maxBytes: 100,
|
||||||
}), /messaging platform/);
|
}), /messaging platform/);
|
||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 3);
|
||||||
|
|
||||||
const redirectCalls = [];
|
const redirectCalls = [];
|
||||||
const redirectingApi = new SlackApi({
|
const redirectingApi = new SlackApi({
|
||||||
|
|
@ -448,6 +455,29 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
|
||||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
|
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Slack resolves file_access metadata through files.info', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const api = new SlackApi({
|
||||||
|
botToken: BOT_TOKEN,
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
file: {
|
||||||
|
id: 'F12345678',
|
||||||
|
name: 'connect.txt',
|
||||||
|
mimetype: 'text/plain',
|
||||||
|
url_private: 'https://files.slack.com/files-pri/T123-F123/connect.txt',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await api.fileInfo({ fileId: 'F12345678' })).name, 'connect.txt');
|
||||||
|
assert.equal(calls[0].url.pathname.endsWith('/files.info'), true);
|
||||||
|
assert.equal(calls[0].options.body, 'file=F12345678');
|
||||||
|
assert.equal(calls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
|
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
|
||||||
const workspaceCalls = [];
|
const workspaceCalls = [];
|
||||||
const workspaceApi = new SlackApi({
|
const workspaceApi = new SlackApi({
|
||||||
|
|
@ -711,7 +741,7 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
||||||
assert.equal(botMessage, null);
|
assert.equal(botMessage, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Slack accepts image file shares and keeps other files out of image prompts', async () => {
|
test('Slack keeps image shares in image prompts and exposes ordinary files lazily', async () => {
|
||||||
const loads = [];
|
const loads = [];
|
||||||
const direct = normalizeSlackEvent({
|
const direct = normalizeSlackEvent({
|
||||||
event_id: 'Ev010',
|
event_id: 'Ev010',
|
||||||
|
|
@ -734,14 +764,32 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
||||||
},
|
},
|
||||||
}, 'U12345678', {
|
}, 'U12345678', {
|
||||||
loadFile: async (url, options) => {
|
loadFile: async (url, options) => {
|
||||||
loads.push({ url, options });
|
loads.push({ kind: 'image', url, options });
|
||||||
return Buffer.from('image');
|
return Buffer.from('image');
|
||||||
},
|
},
|
||||||
|
loadFileStream: async (url, options) => {
|
||||||
|
loads.push({ kind: 'file', url, options });
|
||||||
|
return { stream: (async function* content() { yield Buffer.from('file'); }()) };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(direct.images.length, 1);
|
assert.equal(direct.images.length, 1);
|
||||||
assert.equal(direct.images[0].name, 'screen.png');
|
assert.equal(direct.images[0].name, 'screen.png');
|
||||||
await direct.images[0].load({ maxBytes: 5_000 });
|
await direct.images[0].load({ maxBytes: 5_000 });
|
||||||
assert.match(loads[0].url, /screen\.png$/);
|
assert.match(loads[0].url, /screen\.png$/);
|
||||||
|
assert.equal(direct.files.length, 1);
|
||||||
|
assert.deepEqual({
|
||||||
|
name: direct.files[0].name,
|
||||||
|
mediaType: direct.files[0].mediaType,
|
||||||
|
size: direct.files[0].size,
|
||||||
|
}, { name: 'notes.txt', mediaType: 'text/plain', size: 20 });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loadedFile = await direct.files[0].load({ signal: controller.signal });
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of loadedFile.stream) chunks.push(Buffer.from(chunk));
|
||||||
|
assert.equal(Buffer.concat(chunks).toString(), 'file');
|
||||||
|
assert.match(loads[1].url, /notes\.txt$/);
|
||||||
|
assert.equal(loads[1].kind, 'file');
|
||||||
|
assert.deepEqual(loads[1].options, { signal: controller.signal });
|
||||||
|
|
||||||
const group = normalizeSlackEvent({
|
const group = normalizeSlackEvent({
|
||||||
event_id: 'Ev011',
|
event_id: 'Ev011',
|
||||||
|
|
@ -757,6 +805,7 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
||||||
}, 'U12345678');
|
}, 'U12345678');
|
||||||
assert.equal(group.addressed, true);
|
assert.equal(group.addressed, true);
|
||||||
assert.equal(group.images.length, 1);
|
assert.equal(group.images.length, 1);
|
||||||
|
assert.deepEqual(group.files, []);
|
||||||
|
|
||||||
assert.equal(normalizeSlackEvent({
|
assert.equal(normalizeSlackEvent({
|
||||||
event_id: 'Ev012',
|
event_id: 'Ev012',
|
||||||
|
|
@ -767,6 +816,54 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
||||||
}, 'U12345678'), null);
|
}, 'U12345678'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Slack Connect file placeholders load metadata before streaming the file', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const message = normalizeSlackEvent({
|
||||||
|
event_id: 'Ev013',
|
||||||
|
event: {
|
||||||
|
type: 'message',
|
||||||
|
subtype: 'file_share',
|
||||||
|
channel_type: 'im',
|
||||||
|
channel: 'D12345678',
|
||||||
|
user: 'U87654321',
|
||||||
|
ts: '1700000000.013',
|
||||||
|
text: '',
|
||||||
|
files: [{
|
||||||
|
id: 'F12345681',
|
||||||
|
mode: 'file_access',
|
||||||
|
file_access: 'check_file_info',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}, 'U12345678', {
|
||||||
|
loadFileInfo: async (fileId, options) => {
|
||||||
|
calls.push({ kind: 'info', fileId, options });
|
||||||
|
return {
|
||||||
|
id: fileId,
|
||||||
|
name: 'connect-report.pdf',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
url_private_download: 'https://files.slack.com/files-pri/T123-F123/connect-report.pdf',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loadFileStream: async (url, options) => {
|
||||||
|
calls.push({ kind: 'stream', url, options });
|
||||||
|
return { stream: (async function* content() { yield Buffer.from('connect'); }()) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(message.files.length, 1);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loaded = await message.files[0].load({ signal: controller.signal });
|
||||||
|
assert.equal(loaded.name, 'connect-report.pdf');
|
||||||
|
assert.equal(loaded.mediaType, 'application/pdf');
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of loaded.stream) chunks.push(Buffer.from(chunk));
|
||||||
|
assert.equal(Buffer.concat(chunks).toString(), 'connect');
|
||||||
|
assert.deepEqual(calls[0], {
|
||||||
|
kind: 'info', fileId: 'F12345681', options: { signal: controller.signal },
|
||||||
|
});
|
||||||
|
assert.equal(calls[1].kind, 'stream');
|
||||||
|
assert.match(calls[1].url, /connect-report\.pdf$/);
|
||||||
|
});
|
||||||
|
|
||||||
class FakeSocket {
|
class FakeSocket {
|
||||||
#listeners = new Map();
|
#listeners = new Map();
|
||||||
sent = [];
|
sent = [];
|
||||||
|
|
|
||||||
|
|
@ -139,11 +139,16 @@ test('Telegram API resolves and downloads files without exposing arbitrary paths
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png);
|
assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png);
|
||||||
assert.equal(calls.length, 2);
|
const streamed = await api.downloadFileStream({ fileId: 'AgAC_test-file' });
|
||||||
|
const streamedChunks = [];
|
||||||
|
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
|
||||||
|
assert.deepEqual(Buffer.concat(streamedChunks), png);
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
assert.equal(calls[0].options.method, 'POST');
|
assert.equal(calls[0].options.method, 'POST');
|
||||||
assert.equal(calls[1].options.method, 'GET');
|
assert.equal(calls[1].options.method, 'GET');
|
||||||
assert.equal(calls[1].url.hostname, 'api.telegram.org');
|
assert.equal(calls[1].url.hostname, 'api.telegram.org');
|
||||||
assert.match(calls[1].url.pathname, /\/file\/bot.+\/photos\/file_1\.png$/);
|
assert.match(calls[1].url.pathname, /\/file\/bot.+\/photos\/file_1\.png$/);
|
||||||
|
assert.equal(calls[3].options.signal, undefined);
|
||||||
|
|
||||||
const unsafeApi = new TelegramApi({
|
const unsafeApi = new TelegramApi({
|
||||||
token: TOKEN,
|
token: TOKEN,
|
||||||
|
|
@ -799,7 +804,7 @@ test('Telegram compatible mode preserves old routing and private allowlist mode
|
||||||
}), false);
|
}), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
test('Telegram keeps native photos and image documents as images and exposes ordinary documents as files', async () => {
|
||||||
const loads = [];
|
const loads = [];
|
||||||
const groupPhoto = normalizeTelegramUpdate({
|
const groupPhoto = normalizeTelegramUpdate({
|
||||||
update_id: 20,
|
update_id: 20,
|
||||||
|
|
@ -839,9 +844,23 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
||||||
file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000,
|
file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, { botId: '123456789', username: 'HarnessBot' });
|
}, {
|
||||||
|
botId: '123456789',
|
||||||
|
username: 'HarnessBot',
|
||||||
|
loadFile: async (fileId, options) => {
|
||||||
|
loads.push({ fileId, options });
|
||||||
|
return Buffer.from('document');
|
||||||
|
},
|
||||||
|
});
|
||||||
assert.equal(document.images[0].name, 'diagram.png');
|
assert.equal(document.images[0].name, 'diagram.png');
|
||||||
assert.equal(document.images[0].mediaType, 'image/png');
|
assert.equal(document.images[0].mediaType, 'image/png');
|
||||||
|
assert.equal(document.images[0].size, 3_000);
|
||||||
|
assert.deepEqual(document.files, []);
|
||||||
|
await document.images[0].load({ maxBytes: 5_000 });
|
||||||
|
assert.deepEqual(loads[1], {
|
||||||
|
fileId: 'png-document',
|
||||||
|
options: { maxBytes: 5_000 },
|
||||||
|
});
|
||||||
|
|
||||||
const documentWithoutMime = normalizeTelegramUpdate({
|
const documentWithoutMime = normalizeTelegramUpdate({
|
||||||
update_id: 23,
|
update_id: 23,
|
||||||
|
|
@ -852,8 +871,11 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
||||||
document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 },
|
document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 },
|
||||||
},
|
},
|
||||||
}, { botId: '123456789', username: 'HarnessBot' });
|
}, { botId: '123456789', username: 'HarnessBot' });
|
||||||
|
assert.equal(documentWithoutMime.images[0].name, 'diagram.webp');
|
||||||
assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp');
|
assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp');
|
||||||
|
assert.deepEqual(documentWithoutMime.files, []);
|
||||||
|
|
||||||
|
const fileLoads = [];
|
||||||
const pdf = normalizeTelegramUpdate({
|
const pdf = normalizeTelegramUpdate({
|
||||||
update_id: 22,
|
update_id: 22,
|
||||||
message: {
|
message: {
|
||||||
|
|
@ -862,8 +884,26 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
||||||
from: { id: 42, is_bot: false },
|
from: { id: 42, is_bot: false },
|
||||||
document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' },
|
document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' },
|
||||||
},
|
},
|
||||||
}, { botId: '123456789', username: 'HarnessBot' });
|
}, {
|
||||||
|
botId: '123456789',
|
||||||
|
username: 'HarnessBot',
|
||||||
|
loadFileStream: async (fileId, options) => {
|
||||||
|
fileLoads.push({ fileId, options });
|
||||||
|
return { stream: (async function* content() { yield Buffer.from('pdf'); }()) };
|
||||||
|
},
|
||||||
|
});
|
||||||
assert.deepEqual(pdf.images, []);
|
assert.deepEqual(pdf.images, []);
|
||||||
|
assert.equal(pdf.files[0].name, 'file.pdf');
|
||||||
|
assert.equal(pdf.files[0].mediaType, 'application/pdf');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loadedPdf = await pdf.files[0].load({ signal: controller.signal });
|
||||||
|
const pdfChunks = [];
|
||||||
|
for await (const chunk of loadedPdf.stream) pdfChunks.push(Buffer.from(chunk));
|
||||||
|
assert.equal(Buffer.concat(pdfChunks).toString(), 'pdf');
|
||||||
|
assert.deepEqual(fileLoads, [{
|
||||||
|
fileId: 'pdf-document',
|
||||||
|
options: { signal: controller.signal },
|
||||||
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,78 @@ test('Enterprise WeChat preserves mixed-message text and image order', async ()
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Enterprise WeChat exposes native file callbacks through the SDK downloader without file limits', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const bytes = Buffer.from('wecom-native-file');
|
||||||
|
const message = wecomInboundMessage(frame({
|
||||||
|
msgtype: 'file',
|
||||||
|
text: undefined,
|
||||||
|
file: {
|
||||||
|
url: 'https://wecom.example/encrypted-file',
|
||||||
|
aeskey: 'file-specific-key',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
downloadFile: async (url, aeskey) => {
|
||||||
|
calls.push({ url, aeskey });
|
||||||
|
return { buffer: bytes, filename: '企业微信报告.pdf' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(message.content, '');
|
||||||
|
assert.deepEqual(message.images, []);
|
||||||
|
assert.equal(message.files.length, 1);
|
||||||
|
assert.equal(message.files[0].name, 'file');
|
||||||
|
assert.equal(calls.length, 0, 'file download stays lazy at normalization time');
|
||||||
|
assert.deepEqual(await message.files[0].load({}), {
|
||||||
|
data: bytes,
|
||||||
|
name: '企业微信报告.pdf',
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
url: 'https://wecom.example/encrypted-file',
|
||||||
|
aeskey: 'file-specific-key',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
|
||||||
|
const transport = testClient();
|
||||||
|
const bytes = Buffer.from('wecom-bridge-file');
|
||||||
|
const downloads = [];
|
||||||
|
const prompts = [];
|
||||||
|
transport.client.downloadFile = async (url, aeskey) => {
|
||||||
|
downloads.push({ url, aeskey });
|
||||||
|
return { buffer: bytes, filename: '企微报告.docx' };
|
||||||
|
};
|
||||||
|
const bridge = new WecomHarnessBridge({
|
||||||
|
client: transport.client,
|
||||||
|
generateStreamId: () => 'file-stream',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, prompt, options) => {
|
||||||
|
const loaded = await options.files[0].load({ signal: options.signal });
|
||||||
|
prompts.push({ sessionId, prompt, name: options.files[0].name, loaded });
|
||||||
|
return '文件已收到';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: state(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(frame({
|
||||||
|
msgid: 'wecom-native-file',
|
||||||
|
msgtype: 'file',
|
||||||
|
text: undefined,
|
||||||
|
file: { url: 'https://wecom.example/file', aeskey: 'file-key' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(downloads, [{ url: 'https://wecom.example/file', aeskey: 'file-key' }]);
|
||||||
|
assert.deepEqual(prompts, [{
|
||||||
|
sessionId: 'session-existing',
|
||||||
|
prompt: '',
|
||||||
|
name: 'file',
|
||||||
|
loaded: { data: bytes, name: '企微报告.docx' },
|
||||||
|
}]);
|
||||||
|
assert.equal(transport.streamed.at(-1).content, '文件已收到');
|
||||||
|
});
|
||||||
|
|
||||||
test('Enterprise WeChat starts image download before an earlier conversation turn finishes', async () => {
|
test('Enterprise WeChat starts image download before an earlier conversation turn finishes', async () => {
|
||||||
const transport = testClient();
|
const transport = testClient();
|
||||||
const firstStarted = deferred();
|
const firstStarted = deferred();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
createWeixinApi,
|
createWeixinApi,
|
||||||
decryptWeixinImage,
|
decryptWeixinImage,
|
||||||
|
extractWeixinFiles,
|
||||||
extractWeixinText,
|
extractWeixinText,
|
||||||
normalizeWeixinApiBaseUrl,
|
normalizeWeixinApiBaseUrl,
|
||||||
parseWeixinImageAesKey,
|
parseWeixinImageAesKey,
|
||||||
|
|
@ -65,6 +66,42 @@ test('iLink image references download lazily from the canonical CDN and decrypt
|
||||||
assert.equal(calls[0].init.redirect, 'manual');
|
assert.equal(calls[0].init.redirect, 'manual');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('iLink native file items download lazily and decrypt without image size or type rules', async () => {
|
||||||
|
const key = randomBytes(16);
|
||||||
|
const plaintext = Buffer.from('weixin-native-file');
|
||||||
|
const ciphertext = encryptImage(plaintext, key);
|
||||||
|
const calls = [];
|
||||||
|
const files = extractWeixinFiles({
|
||||||
|
item_list: [{
|
||||||
|
type: 4,
|
||||||
|
file_item: {
|
||||||
|
media: {
|
||||||
|
encrypt_query_param: 'native-file-ticket',
|
||||||
|
aes_key: Buffer.from(key.toString('hex')).toString('base64'),
|
||||||
|
encrypt_type: 1,
|
||||||
|
},
|
||||||
|
file_name: '微信报告.zip',
|
||||||
|
len: String(plaintext.length),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url: url.toString(), init });
|
||||||
|
return new Response(ciphertext);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(files.length, 1);
|
||||||
|
assert.equal(files[0].name, '微信报告.zip');
|
||||||
|
assert.equal(files[0].size, plaintext.length);
|
||||||
|
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||||
|
assert.deepEqual(await files[0].load({}), plaintext);
|
||||||
|
assert.deepEqual(calls, [{
|
||||||
|
url: 'https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=native-file-ticket',
|
||||||
|
init: { method: 'GET', signal: undefined, redirect: 'manual' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
test('Weixin image keys support both documented CDN encodings and reject unsafe URLs', () => {
|
test('Weixin image keys support both documented CDN encodings and reject unsafe URLs', () => {
|
||||||
const key = randomBytes(16);
|
const key = randomBytes(16);
|
||||||
assert.equal(parseWeixinImageAesKey({ aeskey: key.toString('hex') }).equals(key), true);
|
assert.equal(parseWeixinImageAesKey({ aeskey: key.toString('hex') }).equals(key), true);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
import {
|
||||||
|
createWeixinBridgeStatus,
|
||||||
|
weixinInboundMessage,
|
||||||
|
WeixinHarnessBridge,
|
||||||
|
} from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||||
import {
|
import {
|
||||||
OUTBOUND_ARTIFACT_TOOL,
|
OUTBOUND_ARTIFACT_TOOL,
|
||||||
|
|
@ -78,6 +82,125 @@ const PNG_BYTES = Buffer.from([
|
||||||
0x01, 0x02, 0x03,
|
0x01, 0x02, 0x03,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
test('Weixin bridge normalization keeps native file items separate from images', async () => {
|
||||||
|
const bytes = Buffer.from('weixin-file');
|
||||||
|
const source = { name: '微信文档.docx', load: async () => bytes };
|
||||||
|
const calls = [];
|
||||||
|
const inbound = weixinInboundMessage(message('weixin-file', '', {
|
||||||
|
item_list: [{ type: 4, file_item: { file_name: '微信文档.docx', media: {} } }],
|
||||||
|
}), {
|
||||||
|
inboundImages: () => [],
|
||||||
|
inboundFiles: (value) => {
|
||||||
|
calls.push(value);
|
||||||
|
return [source];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(inbound.content, '');
|
||||||
|
assert.deepEqual(inbound.images, []);
|
||||||
|
assert.deepEqual(inbound.files, [source]);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Weixin bridge hands a native file source to the current Harness turn', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:owner-user', 'session-native-file');
|
||||||
|
const bytes = Buffer.from('weixin-bridge-file');
|
||||||
|
const prompts = [];
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: {
|
||||||
|
inboundImages: () => [],
|
||||||
|
inboundFiles: () => [{ name: '微信报告.zip', load: async () => bytes }],
|
||||||
|
sendText: async ({ text }) => sent.push(text),
|
||||||
|
},
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
token: 'token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (sessionId, prompt, options) => {
|
||||||
|
prompts.push({
|
||||||
|
sessionId,
|
||||||
|
prompt,
|
||||||
|
name: options.files[0].name,
|
||||||
|
bytes: await options.files[0].load({ signal: options.signal }),
|
||||||
|
});
|
||||||
|
return '文件已收到';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status: createWeixinBridgeStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('weixin-native-file-turn', '', {
|
||||||
|
item_list: [{
|
||||||
|
type: 4,
|
||||||
|
file_item: { file_name: '微信报告.zip', media: {} },
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(prompts, [{
|
||||||
|
sessionId: 'session-native-file',
|
||||||
|
prompt: '',
|
||||||
|
name: '微信报告.zip',
|
||||||
|
bytes,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['文件已收到']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
|
||||||
|
const firstTurn = deferred();
|
||||||
|
const bytes = Buffer.from('weixin-prefetched-file');
|
||||||
|
let asks = 0;
|
||||||
|
let downloads = 0;
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: {
|
||||||
|
inboundImages: () => [],
|
||||||
|
inboundFiles: (value) => value.item_list?.some((item) => item.file_item)
|
||||||
|
? [{
|
||||||
|
name: 'queued.bin',
|
||||||
|
load: async () => {
|
||||||
|
downloads += 1;
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
sendText: async () => {},
|
||||||
|
},
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
token: 'token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (_sessionId, _prompt, options) => {
|
||||||
|
asks += 1;
|
||||||
|
if (asks === 1) return firstTurn.promise;
|
||||||
|
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||||
|
return '第二条完成';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = bridge.accept(message('weixin-prefetch-first', '先等待'));
|
||||||
|
await eventually(() => asks === 1);
|
||||||
|
const second = bridge.accept(message('weixin-prefetch-second', '', {
|
||||||
|
item_list: [{
|
||||||
|
type: 4,
|
||||||
|
file_item: { file_name: 'queued.bin', media: {} },
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await eventually(() => downloads === 1, 'queued Weixin file did not start downloading');
|
||||||
|
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||||
|
firstTurn.resolve('第一条完成');
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
async function committedArtifact(t, fileName, content) {
|
async function committedArtifact(t, fileName, content) {
|
||||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||||
import {
|
import {
|
||||||
WhatsappRuntime,
|
WhatsappRuntime,
|
||||||
|
createWhatsappMediaDownloader,
|
||||||
normalizeWhatsappMessage,
|
normalizeWhatsappMessage,
|
||||||
whatsappInboundAllowed,
|
whatsappInboundAllowed,
|
||||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||||
|
|
@ -255,15 +256,21 @@ test('WhatsApp Web session accepts recent append events without replaying stale
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
|
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const received = [];
|
const received = [];
|
||||||
|
const contexts = [];
|
||||||
|
const socket = {
|
||||||
|
ev: events,
|
||||||
|
updateMediaMessage: async () => {},
|
||||||
|
end: async () => {},
|
||||||
|
logout: async () => {},
|
||||||
|
};
|
||||||
const session = await createWhatsappWebSession({
|
const session = await createWhatsappWebSession({
|
||||||
authDir: root,
|
authDir: root,
|
||||||
onQr: () => {},
|
onQr: () => {},
|
||||||
onMessage: async (message) => { received.push(message.key.id); },
|
onMessage: async (message, context) => {
|
||||||
makeSocket: () => ({
|
received.push(message.key.id);
|
||||||
ev: events,
|
contexts.push(context);
|
||||||
end: async () => {},
|
},
|
||||||
logout: async () => {},
|
makeSocket: () => socket,
|
||||||
}),
|
|
||||||
loadAuthState: async () => ({
|
loadAuthState: async () => ({
|
||||||
state: {
|
state: {
|
||||||
creds: {},
|
creds: {},
|
||||||
|
|
@ -286,9 +293,33 @@ test('WhatsApp Web session accepts recent append events without replaying stale
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.deepEqual(received, ['recent', 'notify']);
|
assert.deepEqual(received, ['recent', 'notify']);
|
||||||
|
assert.equal(contexts[0].socket, socket);
|
||||||
|
assert.equal(contexts[1].socket, socket);
|
||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('WhatsApp media downloader supplies Baileys reupload context', async () => {
|
||||||
|
const reuploaded = [];
|
||||||
|
const socket = {
|
||||||
|
updateMediaMessage: async (message) => {
|
||||||
|
reuploaded.push(message);
|
||||||
|
return { ...message, reuploaded: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let receivedContext;
|
||||||
|
const download = createWhatsappMediaDownloader({
|
||||||
|
socket,
|
||||||
|
logger: { info() {} },
|
||||||
|
download: async (_message, _type, _options, context) => {
|
||||||
|
receivedContext = context;
|
||||||
|
return context.reuploadRequest({ key: { id: 'expired-media' } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await download({}, 'stream', {})).reuploaded, true);
|
||||||
|
assert.equal(typeof receivedContext.logger.info, 'function');
|
||||||
|
assert.deepEqual(reuploaded, [{ key: { id: 'expired-media' } }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
|
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
|
||||||
const direct = normalizeWhatsappMessage({
|
const direct = normalizeWhatsappMessage({
|
||||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||||
|
|
@ -371,7 +402,7 @@ test('WhatsApp access modes allow self-chat, selected contacts, or the existing
|
||||||
}), true);
|
}), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
|
test('WhatsApp keeps native and document images as images and exposes ordinary documents as files', async () => {
|
||||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -441,9 +472,52 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
|
||||||
url: 'https://mmg.whatsapp.net/document',
|
url: 'https://mmg.whatsapp.net/document',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, ACCOUNT_JID);
|
}, ACCOUNT_JID, {
|
||||||
|
download: async (raw, type, options) => {
|
||||||
|
calls.push({ raw, type, options });
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() { yield Buffer.from('document'); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
assert.equal(document.images[0].name, 'diagram.webp');
|
assert.equal(document.images[0].name, 'diagram.webp');
|
||||||
assert.equal(document.images[0].mediaType, 'image/webp');
|
assert.equal(document.images[0].mediaType, 'image/webp');
|
||||||
|
assert.equal(document.images[0].size, 2_000);
|
||||||
|
assert.deepEqual(document.files, []);
|
||||||
|
const documentController = new AbortController();
|
||||||
|
assert.equal((await document.images[0].load({
|
||||||
|
signal: documentController.signal,
|
||||||
|
maxBytes: 5_000,
|
||||||
|
})).toString(), 'document');
|
||||||
|
assert.equal(calls[1].type, 'stream');
|
||||||
|
assert.equal(calls[1].options.options.signal instanceof AbortSignal, true);
|
||||||
|
|
||||||
|
const ordinaryDocument = normalizeWhatsappMessage({
|
||||||
|
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-pdf-1', fromMe: false },
|
||||||
|
message: {
|
||||||
|
documentMessage: {
|
||||||
|
mimetype: 'application/pdf', fileName: 'report.pdf', fileLength: 4_000,
|
||||||
|
url: 'https://mmg.whatsapp.net/document-pdf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ACCOUNT_JID, {
|
||||||
|
download: async (raw, type, options) => {
|
||||||
|
calls.push({ raw, type, options });
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() { yield Buffer.from('pdf'); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(ordinaryDocument.images, []);
|
||||||
|
assert.equal(ordinaryDocument.files[0].name, 'report.pdf');
|
||||||
|
assert.equal(ordinaryDocument.files[0].mediaType, 'application/pdf');
|
||||||
|
assert.equal(ordinaryDocument.files[0].size, 4_000);
|
||||||
|
const loadedDocument = await ordinaryDocument.files[0].load({ signal: documentController.signal });
|
||||||
|
const documentChunks = [];
|
||||||
|
for await (const chunk of loadedDocument.stream) documentChunks.push(Buffer.from(chunk));
|
||||||
|
assert.equal(Buffer.concat(documentChunks).toString(), 'pdf');
|
||||||
|
assert.equal(calls[2].type, 'stream');
|
||||||
|
assert.equal(calls[2].options.options.signal, documentController.signal);
|
||||||
|
|
||||||
for (const [index, wrapper] of [
|
for (const [index, wrapper] of [
|
||||||
'viewOnceMessage',
|
'viewOnceMessage',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -88,14 +91,16 @@ test('Host control executor refuses idle, replaced, closed, and foreign turns wi
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Host executors preserve HTTP fallback when AgentRegistry or attachment is absent', async () => {
|
test('Host executors preserve HTTP fallback when AgentRegistry or attachment is absent', async () => {
|
||||||
assert.deepEqual(createHarnessSessionExecutors({}), {
|
const withoutRegistry = createHarnessSessionExecutors({});
|
||||||
controlExecutor: undefined,
|
assert.equal(withoutRegistry.controlExecutor, undefined);
|
||||||
sessionMaintenanceExecutor: undefined,
|
assert.equal(withoutRegistry.sessionMaintenanceExecutor, undefined);
|
||||||
});
|
assert.equal(typeof withoutRegistry.fileIngressExecutor, 'function');
|
||||||
assert.deepEqual(createHarnessSessionExecutors({ get() { throw new Error('not injected'); } }), {
|
const inaccessibleRegistry = createHarnessSessionExecutors({
|
||||||
controlExecutor: undefined,
|
get() { throw new Error('not injected'); },
|
||||||
sessionMaintenanceExecutor: undefined,
|
|
||||||
});
|
});
|
||||||
|
assert.equal(inaccessibleRegistry.controlExecutor, undefined);
|
||||||
|
assert.equal(inaccessibleRegistry.sessionMaintenanceExecutor, undefined);
|
||||||
|
assert.equal(typeof inaccessibleRegistry.fileIngressExecutor, 'function');
|
||||||
|
|
||||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(
|
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(
|
||||||
contextWith({ get: () => undefined }),
|
contextWith({ get: () => undefined }),
|
||||||
|
|
@ -115,6 +120,63 @@ test('Host executors preserve HTTP fallback when AgentRegistry or attachment is
|
||||||
assert.equal(operated, true);
|
assert.equal(operated, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Host file ingress stages bytes in the exact attached Session cwd', async (t) => {
|
||||||
|
const defaultWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-default-cwd-'));
|
||||||
|
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-session-cwd-'));
|
||||||
|
t.after(() => Promise.all([
|
||||||
|
rm(defaultWorkspace, { recursive: true, force: true }),
|
||||||
|
rm(sessionWorkspace, { recursive: true, force: true }),
|
||||||
|
]));
|
||||||
|
const agent = {
|
||||||
|
session: {
|
||||||
|
header: { id: 'session-exact-cwd', cwd: sessionWorkspace },
|
||||||
|
events: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const registry = {
|
||||||
|
get: (sessionId) => sessionId === 'session-exact-cwd' ? agent : undefined,
|
||||||
|
};
|
||||||
|
const { fileIngressExecutor } = createHarnessSessionExecutors(contextWith(registry));
|
||||||
|
|
||||||
|
const staged = await fileIngressExecutor({
|
||||||
|
sessionId: 'session-exact-cwd',
|
||||||
|
workspace: defaultWorkspace,
|
||||||
|
files: [{ name: 'attached.txt', data: Buffer.from('session bytes') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(staged.files.length, 1);
|
||||||
|
assert.equal(staged.files[0].name, 'attached.txt');
|
||||||
|
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-attached\.txt$/);
|
||||||
|
assert.equal(
|
||||||
|
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
|
||||||
|
'session bytes',
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
readFile(resolve(defaultWorkspace, staged.files[0].path)),
|
||||||
|
/ENOENT/,
|
||||||
|
'the plugin/default workspace must not receive another Session attachment',
|
||||||
|
);
|
||||||
|
await staged.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host file ingress uses session.list cwd while the Session is still cold', async (t) => {
|
||||||
|
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-cold-session-cwd-'));
|
||||||
|
t.after(() => rm(sessionWorkspace, { recursive: true, force: true }));
|
||||||
|
const { fileIngressExecutor } = createHarnessSessionExecutors({});
|
||||||
|
|
||||||
|
const staged = await fileIngressExecutor({
|
||||||
|
sessionId: 'session-cold',
|
||||||
|
workspace: sessionWorkspace,
|
||||||
|
files: [{ name: 'cold.txt', data: Buffer.from('cold session bytes') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
|
||||||
|
'cold session bytes',
|
||||||
|
);
|
||||||
|
await staged.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
test('Host maintenance executor claims idle synchronously and reports busy with a stable code', async () => {
|
test('Host maintenance executor claims idle synchronously and reports busy with a stable code', async () => {
|
||||||
const maintenanceController = new AbortController();
|
const maintenanceController = new AbortController();
|
||||||
let operationStarted = false;
|
let operationStarted = false;
|
||||||
|
|
|
||||||
172
test/inbound-file.test.mjs
Normal file
172
test/inbound-file.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
mkdtemp,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InboundFileError,
|
||||||
|
stageInboundFiles,
|
||||||
|
} from '../src/channels/shared/inbound-file.mjs';
|
||||||
|
|
||||||
|
async function workspace(t) {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-inbound-file-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteStagedPath(root, file) {
|
||||||
|
assert.equal(isAbsolute(file.path), false, 'Harness receives a workspace-relative path');
|
||||||
|
const path = resolve(root, file.path);
|
||||||
|
assert.equal(path.startsWith(`${resolve(root)}/`), true, 'staged path stays in the Session cwd');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stageInboundFiles preserves exact bytes, including a zero-byte file', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
const binary = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x0a, 0x0d]);
|
||||||
|
const staged = await stageInboundFiles({
|
||||||
|
files: [
|
||||||
|
{ name: 'binary.dat', data: binary, mediaType: 'application/octet-stream' },
|
||||||
|
{ name: 'empty.txt', data: Buffer.alloc(0), mediaType: 'text/plain' },
|
||||||
|
],
|
||||||
|
}, { workspace: root });
|
||||||
|
|
||||||
|
assert.deepEqual(staged.files.map(({ name, mediaType }) => ({ name, mediaType })), [
|
||||||
|
{ name: 'binary.dat', mediaType: 'application/octet-stream' },
|
||||||
|
{ name: 'empty.txt', mediaType: 'text/plain' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
await readFile(absoluteStagedPath(root, staged.files[0])),
|
||||||
|
binary,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await readFile(absoluteStagedPath(root, staged.files[1])),
|
||||||
|
Buffer.alloc(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const turnDirectory = resolve(root, staged.files[0].path, '..');
|
||||||
|
await staged.cleanup();
|
||||||
|
await assert.rejects(readFile(resolve(turnDirectory, '01-binary.dat')), /ENOENT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stageInboundFiles writes async streams without changing their bytes', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
const chunks = [
|
||||||
|
Buffer.from([0xde, 0xad]),
|
||||||
|
new Uint8Array([0xbe, 0xef]),
|
||||||
|
Buffer.from('stream tail'),
|
||||||
|
];
|
||||||
|
const expected = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||||
|
const staged = await stageInboundFiles({
|
||||||
|
files: [{
|
||||||
|
name: 'stream.bin',
|
||||||
|
load: async () => ({
|
||||||
|
stream: Readable.from(chunks),
|
||||||
|
name: 'provider-stream.bin',
|
||||||
|
mimetype: 'application/x-provider-stream',
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
}, { workspace: root });
|
||||||
|
|
||||||
|
assert.deepEqual(staged.files, [{
|
||||||
|
name: 'provider-stream.bin',
|
||||||
|
path: staged.files[0].path,
|
||||||
|
mediaType: 'application/x-provider-stream',
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(await readFile(absoluteStagedPath(root, staged.files[0])), expected);
|
||||||
|
await staged.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stageInboundFiles reports failures raised while streaming provider bytes as file downloads', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
await assert.rejects(stageInboundFiles({
|
||||||
|
files: [{
|
||||||
|
name: 'broken.bin',
|
||||||
|
load: async () => ({
|
||||||
|
stream: Readable.from((async function* brokenStream() {
|
||||||
|
yield Buffer.from('partial');
|
||||||
|
throw new Error('provider stream disconnected');
|
||||||
|
}())),
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
}, { workspace: root }), (error) => (
|
||||||
|
error instanceof InboundFileError
|
||||||
|
&& error.code === 'inbound-file-download-failed'
|
||||||
|
&& error.userMessage === '文件下载失败,请重新发送后再试。'
|
||||||
|
&& error.cause?.message === 'provider stream disconnected'
|
||||||
|
));
|
||||||
|
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stageInboundFiles removes the whole partial batch when any file download fails', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
let secondAttempted = false;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
stageInboundFiles({
|
||||||
|
files: [
|
||||||
|
{ name: 'first.txt', data: Buffer.from('must be removed') },
|
||||||
|
{
|
||||||
|
name: 'second.txt',
|
||||||
|
async load() {
|
||||||
|
secondAttempted = true;
|
||||||
|
throw new Error('private signed URL expired');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'third.txt',
|
||||||
|
async load() {
|
||||||
|
assert.fail('files after a failed member must not be downloaded');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, { workspace: root }),
|
||||||
|
(error) => error instanceof InboundFileError
|
||||||
|
&& error.code === 'inbound-file-download-failed'
|
||||||
|
&& error.userMessage === '文件下载失败,请重新发送后再试。',
|
||||||
|
);
|
||||||
|
assert.equal(secondAttempted, true);
|
||||||
|
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stageInboundFiles keeps display names but makes traversal-like storage names path-safe', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
const staged = await stageInboundFiles({
|
||||||
|
files: [{
|
||||||
|
name: '../../private\\folder/..\\report:?*.txt\u0000\n',
|
||||||
|
data: Buffer.from('safe'),
|
||||||
|
}],
|
||||||
|
}, { workspace: root });
|
||||||
|
|
||||||
|
assert.equal(staged.files[0].name, 'report:?*.txt');
|
||||||
|
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-report___\.txt$/);
|
||||||
|
assert.equal(await readFile(absoluteStagedPath(root, staged.files[0]), 'utf8'), 'safe');
|
||||||
|
await staged.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stageInboundFiles adds no count, extension, MIME, or declared-size acceptance limits', async (t) => {
|
||||||
|
const root = await workspace(t);
|
||||||
|
const sources = Array.from({ length: 33 }, (_, index) => ({
|
||||||
|
name: index === 0 ? 'no-extension' : `unknown-${index}.unsupported-${index}`,
|
||||||
|
mediaType: `application/x-unknown-${index}`,
|
||||||
|
size: Number.MAX_SAFE_INTEGER,
|
||||||
|
data: Buffer.from(`file-${index}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const staged = await stageInboundFiles({ files: sources }, { workspace: root });
|
||||||
|
assert.equal(staged.files.length, 33);
|
||||||
|
assert.deepEqual(
|
||||||
|
await Promise.all(staged.files.map((file) => readFile(absoluteStagedPath(root, file), 'utf8'))),
|
||||||
|
sources.map((_source, index) => `file-${index}`),
|
||||||
|
);
|
||||||
|
assert.equal(staged.files[0].name, 'no-extension');
|
||||||
|
assert.equal(staged.files.at(-1).mediaType, 'application/x-unknown-32');
|
||||||
|
await staged.cleanup();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue