mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 18:53:17 +08:00
merge: integrate upstream v2.0.0 (files, images, i18n) into feishu-card-system
- Resolve conflicts in feishu-cards.mjs / bridge.mjs / control-command.mjs / lib - Keep card-system enhancements: menuCard/settingsCard/presetCard/modelCard, /m newCard, card form submit, model select - Adopt upstream i18n t() wrapping + file/image delivery + inbound files - Add missing English dictionary entries for new feishu card strings - Rebuild lib (package now 2.0.0)
This commit is contained in:
commit
044ab74c28
167 changed files with 22497 additions and 1793 deletions
|
|
@ -2,11 +2,17 @@ import test from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
|
|
@ -90,6 +96,41 @@ function textClient(sendText) {
|
|||
};
|
||||
}
|
||||
|
||||
async function committedArtifact(t, fileName, content, suffix = '') {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix || 'file'}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: `session-${suffix || 'file'}`, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: `rpc-${suffix || 'file'}` } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix || 'file'}`,
|
||||
rootCallId: `call-${suffix || 'file'}`,
|
||||
token: Symbol(`call-${suffix || 'file'}`),
|
||||
agent,
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const [artifact] = registry.take(agent.session.header.id, 1);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
|
||||
const groupFixture = stateFixture();
|
||||
const groupBridge = new FeishuHarnessBridge({
|
||||
|
|
@ -464,6 +505,63 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
|
|||
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 () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
|
||||
const sent = [];
|
||||
|
|
@ -1879,6 +1977,495 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_1X1,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'feishu-image',
|
||||
expectedProviderIds: ['om-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(
|
||||
subtest,
|
||||
scenario.fileName,
|
||||
scenario.content,
|
||||
`bridge-image-route-${index}`,
|
||||
);
|
||||
const calls = [];
|
||||
const status = bridgeStatus();
|
||||
const resultFor = (file, presentation, providerMessageId) => ({
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation,
|
||||
providerMessageIds: [providerMessageId],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
});
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => { throw new Error('text intentionally unavailable'); }),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
sendImage: async (chatId, file, options) => {
|
||||
calls.push('image');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return resultFor(file, 'feishu-image', 'om-native-image');
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
calls.push('file');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
return resultFor(file, 'feishu-file', 'om-native-file');
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: stateFixture([
|
||||
['p2p:ou_user', `session-feishu-image-route-${index}`],
|
||||
]).state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-feishu-image-route-${index}`, '生成产物'));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-artifacts']]);
|
||||
const order = [];
|
||||
const delivered = [];
|
||||
const notices = [];
|
||||
const status = bridgeStatus();
|
||||
const abort = new AbortController();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
order.push('card-finalized');
|
||||
return { messageId: 'om-card' };
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
order.push(`file:${file.fileName}`);
|
||||
delivered.push({ chatId, file, options });
|
||||
if (file.fileName === 'notes.txt') {
|
||||
const error = new Error('provider detail must stay private');
|
||||
error.code = 'artifact-rate-limited';
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(html);
|
||||
await options.onArtifact(generic);
|
||||
return '两个结果文件已经生成。';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifacts', '生成 HTML 和说明文件并发给我'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(order, ['card-finalized', 'file:result.html', 'file:notes.txt']);
|
||||
assert.equal(delivered[0].chatId, 'oc_chat');
|
||||
assert.deepEqual(delivered[0].options, {
|
||||
replyTo: 'om_artifacts',
|
||||
signal: abort.signal,
|
||||
});
|
||||
assert.equal(delivered[0].file.bytes.toString(), '<h1>result</h1>');
|
||||
assert.equal(delivered[1].file.bytes.toString(), 'notes');
|
||||
assert.equal(status.artifactsSent, 1);
|
||||
assert.equal(status.artifactSendErrors, 1);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /notes\.txt.*限流/);
|
||||
assert.doesNotMatch(notices[0], /provider detail/);
|
||||
});
|
||||
|
||||
test('Feishu tells users to check the chat before retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
|
||||
const notices = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => notices.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
return { messageId: 'om-uncertain-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '结果已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(notices, [
|
||||
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'file-only.txt', 'file only', 'file-only');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-file-only']]);
|
||||
const cardContents = [];
|
||||
const files = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => undefined),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async (content) => cardContents.push(content) });
|
||||
return { messageId: 'om-file-only-card' };
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-only'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_only', '只发送结果文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(cardContents, ['结果文件已生成。']);
|
||||
assert.deepEqual(files, ['file-only.txt']);
|
||||
});
|
||||
|
||||
test('a CardKit finalization failure falls back to text and delivers each artifact once without a second prompt', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.txt', 'fallback result', 'fallback');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-fallback-artifact']]);
|
||||
const sent = [];
|
||||
const files = [];
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization failed');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-fallback-file'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
asks += 1;
|
||||
await options.onArtifact(artifact);
|
||||
return '回答已生成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
bridge.accept(event('om_artifact_card_fallback', '生成并发送文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.deepEqual(sent, ['回答已生成']);
|
||||
assert.deepEqual(files, ['fallback.txt']);
|
||||
});
|
||||
|
||||
test('Feishu still delivers a file-only result when CardKit and fallback text both fail', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
|
||||
const files = [];
|
||||
let fallbackTextAttempts = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => {
|
||||
fallbackTextAttempts += 1;
|
||||
throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
throw new Error('card finalization unavailable');
|
||||
},
|
||||
sendFile: async (_chatId, file) => {
|
||||
files.push(file.fileName);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-file-after-text-failure'],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
};
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_file_after_text_failure', '只生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(files, ['survives-text-failure.txt']);
|
||||
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||||
await t.test(errorCode, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||||
const attemptedTexts = [];
|
||||
const visibleTexts = [];
|
||||
const reactions = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const text = JSON.parse(request.data.content).text;
|
||||
attemptedTexts.push(text);
|
||||
if (text === '文字结果') throw new Error('text transport unavailable');
|
||||
visibleTexts.push(text);
|
||||
return { code: 0, data: {} };
|
||||
} } } },
|
||||
},
|
||||
channel: {
|
||||
addReaction: async (_messageId, emoji) => {
|
||||
reactions.push(emoji);
|
||||
return `reaction-${emoji}`;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
sendFile: async () => {
|
||||
const error = new Error('unsafe result file');
|
||||
error.code = errorCode;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', `session-${errorCode}`]]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-${errorCode}`, '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
|
||||
assert.equal(visibleTexts.length, 1);
|
||||
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
|
||||
assert.deepEqual(reactions, ['OnIt', 'DONE']);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: artifact.deliveryKey,
|
||||
presentation: 'text-fallback',
|
||||
providerMessageIds: [],
|
||||
artifacts: [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'rejected',
|
||||
reason: errorCode,
|
||||
}],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu keeps the generic error when no answer or file failure notice is visible', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
|
||||
const attemptedTexts = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => {
|
||||
attemptedTexts.push(text);
|
||||
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
|
||||
}),
|
||||
channel: {
|
||||
sendFile: async () => {
|
||||
const error = new Error('file transport unavailable');
|
||||
error.code = 'artifact-provider-failed';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字结果';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['p2p:ou_user', 'session-no-visible-failure']]).state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
|
||||
|
||||
assert.equal(attemptedTexts.length, 3);
|
||||
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
|
||||
});
|
||||
|
||||
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'cancel-after-card.txt', 'file bytes', 'cancel-after-card');
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-cancel-after-card']]);
|
||||
const controller = new AbortController();
|
||||
const fallbackTexts = [];
|
||||
let files = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => fallbackTexts.push(text)),
|
||||
channel: {
|
||||
stream: async (_chatId, input) => {
|
||||
await input.markdown({ setContent: async () => undefined });
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
return { messageId: 'om-final-card' };
|
||||
},
|
||||
sendFile: async () => {
|
||||
files += 1;
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '卡片已经完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
signal: controller.signal,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
bridge.accept(event('om_cancel_after_card', '生成文件'));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(files, 0);
|
||||
assert.deepEqual(fallbackTexts, []);
|
||||
});
|
||||
|
||||
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||||
const seen = new Set();
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ function fakeClient(overrides = {}) {
|
|||
recalls: [],
|
||||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
fileUploads: [],
|
||||
imageUploads: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
|
|
@ -28,6 +30,18 @@ function fakeClient(overrides = {}) {
|
|||
},
|
||||
} },
|
||||
im: { v1: {
|
||||
file: {
|
||||
create: async (request) => {
|
||||
calls.fileUploads.push(request);
|
||||
return { file_key: 'file-key-test' };
|
||||
},
|
||||
},
|
||||
image: {
|
||||
create: async (request) => {
|
||||
calls.imageUploads.push(request);
|
||||
return { data: { image_key: 'image-key-test' } };
|
||||
},
|
||||
},
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
|
|
@ -54,6 +68,10 @@ function fakeClient(overrides = {}) {
|
|||
|
||||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile;
|
||||
if (overrides.uploadImage) client.im.v1.image.create = overrides.uploadImage;
|
||||
if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
|
||||
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
|
|
@ -129,3 +147,427 @@ test('VerifiedFeishuChannel checks reaction API results', async () => {
|
|||
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
|
||||
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads a materialized result and replies with a native file message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-html',
|
||||
deliveryKey: 'delivery-html',
|
||||
fileName: 'result.html',
|
||||
mediaType: 'text/html',
|
||||
size: 19,
|
||||
bytes: Buffer.from('<h1>result</h1>'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(calls.fileUploads[0].data.file_type, 'stream');
|
||||
assert.equal(calls.fileUploads[0].data.file_name, 'result.html');
|
||||
assert.equal(calls.fileUploads[0].data.file, file.bytes);
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { file_key: 'file-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-html',
|
||||
presentation: 'feishu-file',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-html', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads an image and replies with a native image message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-image',
|
||||
deliveryKey: 'delivery-image',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
size: 12,
|
||||
bytes: Buffer.from('png contents'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendImage('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 0);
|
||||
assert.equal(calls.imageUploads.length, 1);
|
||||
assert.deepEqual(calls.imageUploads[0].data, {
|
||||
image_type: 'message',
|
||||
image: file.bytes,
|
||||
});
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'image');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { image_key: 'image-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-image',
|
||||
presentation: 'feishu-image',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-image', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
createMessage: async (request) => {
|
||||
calls.replies.push(request);
|
||||
return { code: 0, data: { message_id: 'om-created-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-generic',
|
||||
deliveryKey: 'delivery-generic',
|
||||
fileName: 'result.bin',
|
||||
bytes: Buffer.from('generic'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.replies[0].params, { receive_id_type: 'chat_id' });
|
||||
assert.equal(calls.replies[0].data.receive_id, 'oc_chat');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'file');
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries uncertain message delivery without uploading twice', async () => {
|
||||
const requests = [];
|
||||
let attempts = 0;
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? { code: 230049, msg: 'still sending' }
|
||||
: { code: 0, data: { message_id: 'om-retried-file' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-retry',
|
||||
deliveryKey: 'delivery-retry',
|
||||
fileName: 'retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-retried-file']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after one 230049 retry and never uploads the file twice', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-still-uncertain',
|
||||
deliveryKey: 'delivery-still-uncertain',
|
||||
fileName: 'uncertain.txt',
|
||||
bytes: Buffer.from('uncertain'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.providerCode === 230049);
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel retries an SDK-thrown 230049 with the same file key and UUID', async () => {
|
||||
const requests = [];
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(structuredClone(request));
|
||||
if (requests.length === 1) {
|
||||
const error = new Error('still sending');
|
||||
error.code = 230049;
|
||||
throw error;
|
||||
}
|
||||
return { code: 0, data: { message_id: 'om-after-thrown-230049' } };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
const receipt = await channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-thrown-retry',
|
||||
deliveryKey: 'delivery-thrown-retry',
|
||||
fileName: 'thrown-retry.txt',
|
||||
bytes: Buffer.from('retry'),
|
||||
}, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['om-after-thrown-230049']);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel bounds upload and message waits independently', async () => {
|
||||
const uploadFixture = fakeClient({ uploadFile: async () => new Promise(() => {}) });
|
||||
const uploadChannel = new VerifiedFeishuChannel({
|
||||
client: uploadFixture.client,
|
||||
fileUploadTimeoutMs: 10,
|
||||
fileMessageTimeoutMs: 100,
|
||||
});
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-timeout',
|
||||
deliveryKey: 'delivery-upload-timeout',
|
||||
fileName: 'upload-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
let messageCalls = 0;
|
||||
const messageFixture = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const messageChannel = new VerifiedFeishuChannel({
|
||||
client: messageFixture.client,
|
||||
fileUploadTimeoutMs: 100,
|
||||
fileMessageTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-timeout',
|
||||
deliveryKey: 'delivery-message-timeout',
|
||||
fileName: 'message-timeout.txt',
|
||||
bytes: Buffer.from('timeout'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.code === 'provider-timeout');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
assert.equal(messageCalls, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel rejects waits longer than the 120 second operation timeout', () => {
|
||||
const { client } = fakeClient();
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileUploadTimeoutMs: 120_001 }),
|
||||
/fileUploadTimeoutMs.*120000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => new VerifiedFeishuChannel({ client, fileMessageTimeoutMs: 120_001 }),
|
||||
/fileMessageTimeoutMs.*120000/,
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel stops after an in-flight upload when file delivery is cancelled', async () => {
|
||||
let uploadStarted;
|
||||
const started = new Promise((resolve) => { uploadStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
uploadStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-upload',
|
||||
deliveryKey: 'delivery-cancelled-upload',
|
||||
fileName: 'cancelled.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason);
|
||||
assert.equal(calls.replies.length, 0);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel immediately preserves caller abort during an in-flight message send', async () => {
|
||||
let messageStarted;
|
||||
const started = new Promise((resolve) => { messageStarted = resolve; });
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => {
|
||||
messageStarted();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
|
||||
const sending = channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-message',
|
||||
deliveryKey: 'delivery-cancelled-message',
|
||||
fileName: 'cancelled-message.txt',
|
||||
bytes: Buffer.from('cancelled'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal });
|
||||
|
||||
await started;
|
||||
abort.abort(reason);
|
||||
|
||||
await assert.rejects(sending, (error) => error === reason
|
||||
&& error.code !== 'artifact-delivery-uncertain');
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel does not retry an uncertain file message after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
const reason = new DOMException('runtime stopped', 'AbortError');
|
||||
const requests = [];
|
||||
const { client } = fakeClient({
|
||||
replyMessage: async (request) => {
|
||||
requests.push(request);
|
||||
abort.abort(reason);
|
||||
return { code: 230049, msg: 'still sending' };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-cancelled-retry',
|
||||
deliveryKey: 'delivery-cancelled-retry',
|
||||
fileName: 'cancelled-retry.txt',
|
||||
bytes: Buffer.from('cancelled retry'),
|
||||
}, { replyTo: 'om_user', signal: abort.signal }), (error) => error === reason);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel lets Feishu decide empty and oversize file outcomes', async () => {
|
||||
const uploads = [];
|
||||
const providerCodes = [234010, 234006, 0];
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async (request) => {
|
||||
uploads.push(request);
|
||||
const code = providerCodes.shift();
|
||||
return code === 0 ? {} : { code };
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const base = {
|
||||
artifactId: 'artifact-invalid',
|
||||
deliveryKey: 'delivery-invalid',
|
||||
fileName: 'invalid.bin',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.alloc(0) }),
|
||||
(error) => error.code === 'artifact-empty' && error.providerCode === 234010,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('provider decides') }),
|
||||
(error) => error.code === 'artifact-too-large' && error.providerCode === 234006,
|
||||
);
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('valid bytes') }),
|
||||
(error) => error.code === 'artifact-provider-failed' && !error.message.includes('undefined'),
|
||||
);
|
||||
assert.deepEqual(uploads.map((request) => request.data.file.byteLength), [0, 16, 11]);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel distinguishes upload failure from uncertain final delivery', async () => {
|
||||
const uploadFixture = fakeClient({
|
||||
uploadFile: async () => { throw new Error('upload transport closed'); },
|
||||
});
|
||||
const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client });
|
||||
|
||||
await assert.rejects(uploadChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-upload-transport',
|
||||
deliveryKey: 'delivery-upload-transport',
|
||||
fileName: 'upload.txt',
|
||||
bytes: Buffer.from('upload'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed');
|
||||
assert.equal(uploadFixture.calls.replies.length, 0);
|
||||
|
||||
for (const replyMessage of [
|
||||
async () => { throw new Error('message transport closed'); },
|
||||
async () => ({ code: 0, data: {} }),
|
||||
]) {
|
||||
const messageFixture = fakeClient({ replyMessage });
|
||||
const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client });
|
||||
await assert.rejects(messageChannel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-uncertain-message',
|
||||
deliveryKey: 'delivery-uncertain-message',
|
||||
fileName: 'message.txt',
|
||||
bytes: Buffer.from('message'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
assert.equal(messageFixture.calls.fileUploads.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel keeps explicit final provider codes out of the uncertain bucket', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
replyMessage: async () => ({ code: 99991672, msg: 'missing scope' }),
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-message-permission',
|
||||
deliveryKey: 'delivery-message-permission',
|
||||
fileName: 'permission.txt',
|
||||
bytes: Buffer.from('permission'),
|
||||
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672);
|
||||
assert.equal(calls.fileUploads.length, 1);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel classifies missing file permission without exposing provider details', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const error = new Error('raw tenant detail and private diagnostic');
|
||||
error.code = 99991672;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-permission',
|
||||
deliveryKey: 'delivery-permission',
|
||||
fileName: 'result.html',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.message.includes('im:resource permission')
|
||||
&& !error.message.includes('im:resource:upload')
|
||||
&& !error.message.includes('private diagnostic'),
|
||||
);
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', async () => {
|
||||
const { client } = fakeClient({
|
||||
uploadFile: async () => {
|
||||
const transport = new Error('raw transport diagnostic');
|
||||
transport.code = 'ERR_BAD_REQUEST';
|
||||
throw [transport, {
|
||||
code: 99991672,
|
||||
msg: 'raw provider permission URL',
|
||||
}];
|
||||
},
|
||||
});
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
|
||||
await assert.rejects(
|
||||
channel.sendFile('oc_chat', {
|
||||
artifactId: 'artifact-array-permission',
|
||||
deliveryKey: 'delivery-array-permission',
|
||||
fileName: 'result.txt',
|
||||
bytes: Buffer.from('result'),
|
||||
}),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === 99991672
|
||||
&& !error.message.includes('provider permission URL'),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessReplyTracker,
|
||||
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
createOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
outboundArtifactRegistry,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -582,6 +592,280 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
|
|||
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
|
||||
});
|
||||
|
||||
test('HarnessClient asks do not control file-return tool availability', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
let promptRpcId;
|
||||
let prompted = false;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-artifact-availability', cwd: '/tmp/dsh-feishu-workspace' },
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
promptRpcId = options.rpcId;
|
||||
agent.session.events = [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: promptRpcId } } },
|
||||
];
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
events: [
|
||||
{ event: { type: 'turn/start', seq: 1, data: { turn: 1 } } },
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
data: { turn: 1, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: 'assistant/message',
|
||||
seq: 3,
|
||||
data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'done' }] } },
|
||||
},
|
||||
},
|
||||
{ event: { type: 'turn/end', seq: 4, data: { turn: 1, reason: { kind: 'completed' } } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(await client.ask('session-artifact-availability', 'create a file', {
|
||||
onArtifact: async () => undefined,
|
||||
}), '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) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-file-only-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace,
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
||||
const delivered = [];
|
||||
await writeFile(join(workspace, 'file-only.txt'), 'file only');
|
||||
let promptRpcId;
|
||||
let prompted = false;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-file-only', cwd: workspace },
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
client.rpc = async (method, _payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
promptRpcId = options.rpcId;
|
||||
agent.session.events = [
|
||||
{ type: 'turn/start', data: { turn: 2 } },
|
||||
{ type: 'user/message', data: { turn: 2, source: { rpcId: promptRpcId } } },
|
||||
];
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'file-only-call',
|
||||
token: Symbol('file-only-call'),
|
||||
agent,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
await tool.definition.execute({ path: 'file-only.txt' }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
events: [
|
||||
{ event: { type: 'turn/start', seq: 1, data: { turn: 2 } } },
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
data: { turn: 2, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
{ event: { type: 'turn/end', seq: 3, data: { turn: 2, reason: { kind: 'completed' } } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const answer = await client.ask('session-file-only', 'create and return a file', {
|
||||
onArtifact: async (artifact) => delivered.push(artifact),
|
||||
});
|
||||
assert.equal(answer, '');
|
||||
assert.equal(delivered.length, 1);
|
||||
const file = await materializeOutboundArtifact(delivered[0]);
|
||||
assert.equal(file.bytes.toString(), 'file only');
|
||||
releaseOutboundArtifact(delivered[0]);
|
||||
});
|
||||
|
||||
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
|
||||
|
||||
|
|
@ -667,5 +951,24 @@ test('HarnessReplyTracker emits tool progress without exposing tool results', ()
|
|||
|
||||
assert.deepEqual(tracker.consume([
|
||||
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
|
||||
]), { type: 'status', text: '正在整理结果…' });
|
||||
]), { type: 'status', text: '正在整理结果…', toolName: 'web_search' });
|
||||
});
|
||||
|
||||
test('HarnessReplyTracker keeps every frame of a batched turn in order', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' });
|
||||
const updates = tracker.consumeAll([
|
||||
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-batch' } } },
|
||||
{ type: 'assistant/chunk', seq: 3, data: { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: '先创建再观察:' } } },
|
||||
{ type: 'tool/call', seq: 4, data: { turn: 1, step: 1, name: 'add_observations' } },
|
||||
{ type: 'tool/result', seq: 5, data: { turn: 1, step: 1, error: { message: 'Status code: 404.' } } },
|
||||
{ type: 'tool/call', seq: 6, data: { turn: 1, step: 2, name: 'create_entities' } },
|
||||
]);
|
||||
assert.deepEqual(updates, [
|
||||
{ type: 'text', text: '先创建再观察:' },
|
||||
{ type: 'tool', name: 'add_observations' },
|
||||
{ type: 'status', text: '正在整理结果…', toolName: 'add_observations', error: 'Status code: 404.' },
|
||||
{ type: 'tool', name: 'create_entities' },
|
||||
]);
|
||||
assert.equal(tracker.answer, '先创建再观察:');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
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', () => {
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'image', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
}, {}), { content: '', images: [], files: [] });
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'post', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
}, {}), { content: '', images: [], files: [] });
|
||||
});
|
||||
|
||||
test('conversationKey isolates p2p users and groups', () => {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
const run = fx.registrationRuns.shift();
|
||||
assert.deepEqual(run.options.addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
fixture.resolveRegistration({
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
const alphaState = constructed.runtime.state;
|
||||
assert.equal(constructed.runtime.botId, 'bot_alpha');
|
||||
assert.equal(constructed.runtime.repair, repair);
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_beta',
|
||||
config: {
|
||||
|
|
@ -1235,6 +1236,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
appSecret: 'beta-secret',
|
||||
});
|
||||
const betaState = constructed.runtime.state;
|
||||
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
|
||||
assert.notEqual(alphaState, betaState);
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
|
||||
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue