mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 05:40:45 +08:00
fix: preserve Telegram compatibility and stabilize Office
This commit is contained in:
parent
7f114e1c36
commit
24e690498b
23 changed files with 1679 additions and 314 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
|
@ -11,6 +12,7 @@ import { OfficeConfigStore } from '../../../src/channels/office/config-store.mjs
|
|||
import { OfficeController } from '../../../src/channels/office/office-controller.mjs';
|
||||
import { OfficeTransport } from '../../../src/channels/office/office-transport.mjs';
|
||||
import { OfficeJobExecutor } from '../../../src/channels/office/office-job-executor.mjs';
|
||||
import { OfficeRuntime } from '../../../src/channels/office/office-runtime.mjs';
|
||||
import {
|
||||
OFFICE_PROTOCOL_VERSION,
|
||||
OFFICE_RPC_ENDPOINTS,
|
||||
|
|
@ -30,6 +32,44 @@ async function eventually(predicate, timeoutMs = 2_000) {
|
|||
assert.fail('condition did not become true');
|
||||
}
|
||||
|
||||
function controlledSleep() {
|
||||
const calls = [];
|
||||
const sleep = (delay, _value, { signal } = {}) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const onAbort = () => finish(
|
||||
reject,
|
||||
signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'),
|
||||
);
|
||||
const call = {
|
||||
delay,
|
||||
get settled() { return settled; },
|
||||
resolve: () => finish(resolve),
|
||||
};
|
||||
calls.push(call);
|
||||
if (signal?.aborted) onAbort();
|
||||
else signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
return { calls, sleep };
|
||||
}
|
||||
|
||||
function pendingUntilAbort(signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
|
|
@ -127,6 +167,103 @@ test('AI Office transport uses fixed Job hooks and keeps the lease outside JSON
|
|||
assert.equal(calls[2].options.body.includes('lease-secret'), false);
|
||||
});
|
||||
|
||||
test('AI Office transport satisfies the loopback Office HTTP and SSE contract', async (t) => {
|
||||
const jobId = 'job-1234567890abcdef1234567890abcdef';
|
||||
const leaseToken = 'lease-local-contract-1234567890';
|
||||
const records = [];
|
||||
const json = (response, value, status = 200) => {
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const rawBody = Buffer.concat(chunks).toString('utf8');
|
||||
const path = new URL(request.url, 'http://127.0.0.1').pathname;
|
||||
records.push({
|
||||
path,
|
||||
method: request.method,
|
||||
headers: { ...request.headers },
|
||||
body: rawBody ? JSON.parse(rawBody) : undefined,
|
||||
});
|
||||
if (path.endsWith('/heartbeat')) {
|
||||
json(response, { ok: true, protocolVersion: OFFICE_PROTOCOL_VERSION, jobs: [] });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith('/stream')) {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
response.end([
|
||||
`id: evt-available\nevent: job.available\ndata: {"type":"job.available","jobId":"${jobId}"}\n\n`,
|
||||
`id: evt-approval\nevent: approval.reply\ndata: {"type":"approval.reply","jobId":"${jobId}","approvalId":"approval-local","decision":"approved"}\n\n`,
|
||||
`id: evt-cancel\nevent: job.cancel\ndata: {"type":"job.cancel","jobId":"${jobId}"}\n\n`,
|
||||
].join(''));
|
||||
return;
|
||||
}
|
||||
if (path === `/api/harness/connector/jobs/${jobId}`) {
|
||||
json(response, { job: { id: jobId } });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith('/accept')) {
|
||||
json(response, { leaseToken });
|
||||
return;
|
||||
}
|
||||
if (/\/(renew|progress|approval|result|fail)$/.test(path)) {
|
||||
json(response, { ok: true });
|
||||
return;
|
||||
}
|
||||
json(response, { error: 'not-found' }, 404);
|
||||
})().catch((error) => {
|
||||
json(response, { error: error.message }, 500);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
assert.equal(typeof address, 'object');
|
||||
const transport = new OfficeTransport({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
deviceId: 'local-contract-device',
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
await transport.heartbeat({ protocolVersion: OFFICE_PROTOCOL_VERSION });
|
||||
const events = [];
|
||||
let opened = false;
|
||||
await assert.rejects(() => transport.stream({
|
||||
lastEventId: 'evt-previous',
|
||||
onOpen: () => { opened = true; },
|
||||
onEvent: (event) => events.push(event),
|
||||
}), /stream ended/);
|
||||
await transport.getJob(jobId);
|
||||
assert.equal((await transport.acceptJob(jobId)).leaseToken, leaseToken);
|
||||
await transport.renewJob(jobId, leaseToken);
|
||||
await transport.progressJob(jobId, leaseToken, { kind: 'status', message: 'running' });
|
||||
await transport.requestApproval(jobId, leaseToken, { id: 'approval-local', kind: 'approval' });
|
||||
await transport.completeJob(jobId, leaseToken, { resultMarkdown: 'done', sessionId: 'session-local' });
|
||||
await transport.failJob(jobId, leaseToken, { error: 'contract-only failure payload' });
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(events.map((event) => event.type), [
|
||||
'job.available', 'approval.reply', 'job.cancel',
|
||||
]);
|
||||
assert.ok(records.every((record) => record.headers.authorization === `Bearer ${TOKEN}`));
|
||||
assert.ok(records.every((record) => record.headers['x-harness-device-id'] === 'local-contract-device'));
|
||||
assert.equal(records.find((record) => record.path.endsWith('/stream')).headers['last-event-id'], 'evt-previous');
|
||||
const leased = records.filter((record) => /\/(renew|progress|approval|result|fail)$/.test(record.path));
|
||||
assert.equal(leased.length, 5);
|
||||
for (const record of leased) {
|
||||
assert.equal(record.headers['x-harness-lease-token'], leaseToken);
|
||||
assert.equal(JSON.stringify(record.body ?? null).includes(leaseToken), false);
|
||||
}
|
||||
assert.equal(records.find((record) => record.path.endsWith('/progress')).body.message, 'running');
|
||||
assert.equal(records.find((record) => record.path.endsWith('/result')).body.resultMarkdown, 'done');
|
||||
assert.equal(records.find((record) => record.path.endsWith('/approval')).body.id, 'approval-local');
|
||||
});
|
||||
|
||||
test('AI Office controller stores the token in credentials and returns only safe status', async () => {
|
||||
let stored = null;
|
||||
const credentialStore = credentials();
|
||||
|
|
@ -295,3 +432,301 @@ test('AI Office Job executor claims, reports, approves, and returns one Harness
|
|||
assert.deepEqual(results, [{ resultMarkdown: '# Completed\n\nVerified.', sessionId: 'session-office-one' }]);
|
||||
await executor.close();
|
||||
});
|
||||
|
||||
test('AI Office Job continues when approval polling fails after a successful renewal', async () => {
|
||||
const jobId = 'job-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const clock = controlledSleep();
|
||||
const warnings = [];
|
||||
let getJobCalls = 0;
|
||||
let renewals = 0;
|
||||
let cancellations = 0;
|
||||
let completions = 0;
|
||||
let failures = 0;
|
||||
let approvalRequests = 0;
|
||||
const responses = [];
|
||||
const transport = {
|
||||
getJob: async () => {
|
||||
getJobCalls += 1;
|
||||
if (getJobCalls === 1) return { job: {
|
||||
id: jobId,
|
||||
workspaceAlias: 'office-project',
|
||||
instructionPreset: 'execute',
|
||||
markdown: '# Poll recovery test',
|
||||
} };
|
||||
if (getJobCalls === 2) throw new Error('temporary approval poll outage');
|
||||
return { job: { approval: {
|
||||
id: 'approval-poll-recovery',
|
||||
status: 'approved',
|
||||
} } };
|
||||
},
|
||||
acceptJob: async () => ({ leaseToken: 'lease-poll-recovery' }),
|
||||
renewJob: async () => { renewals += 1; return { ok: true }; },
|
||||
progressJob: async () => ({ ok: true }),
|
||||
requestApproval: async () => { approvalRequests += 1; return { ok: true }; },
|
||||
completeJob: async () => { completions += 1; return { ok: true }; },
|
||||
failJob: async () => { failures += 1; return { ok: true }; },
|
||||
};
|
||||
const executor = new OfficeJobExecutor({
|
||||
config: {
|
||||
maxConcurrency: 1,
|
||||
workspaces: { 'office-project': '/tmp/office-project' },
|
||||
instructionPresets: { execute: 'Execute carefully.' },
|
||||
},
|
||||
transport,
|
||||
createHarness: () => ({
|
||||
createSession: async () => 'session-poll-recovery',
|
||||
ask: async (_sessionId, _prompt, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'approval',
|
||||
interactionId: 'approval-poll-recovery',
|
||||
sessionId: 'session-poll-recovery',
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId: 'session-poll-recovery',
|
||||
approvalId: 'approval-poll-recovery',
|
||||
toolName: 'apply_patch',
|
||||
callId: 'call-poll-recovery',
|
||||
},
|
||||
toolCall: {
|
||||
callId: 'call-poll-recovery',
|
||||
name: 'apply_patch',
|
||||
arguments: '{"patch":"safe"}',
|
||||
},
|
||||
respond: async (value) => { responses.push(value); return { accepted: true }; },
|
||||
});
|
||||
return '# Completed after transient poll failure';
|
||||
},
|
||||
rpc: async () => { cancellations += 1; return { ok: true }; },
|
||||
}),
|
||||
logger: { warn: (...args) => warnings.push(args) },
|
||||
sleepImpl: clock.sleep,
|
||||
});
|
||||
|
||||
assert.equal(executor.offer(jobId), true);
|
||||
await eventually(() => approvalRequests === 1
|
||||
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
|
||||
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
|
||||
await eventually(() => renewals === 1 && warnings.length === 1
|
||||
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
|
||||
assert.equal(executor.status.running, 1);
|
||||
assert.equal(cancellations, 0);
|
||||
assert.equal(responses.length, 0);
|
||||
assert.match(warnings[0].join(' '), /approval poll failed/);
|
||||
|
||||
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
|
||||
await eventually(() => executor.status.completed === 1);
|
||||
assert.equal(getJobCalls, 3);
|
||||
assert.equal(renewals, 2);
|
||||
assert.equal(responses[0].value.outcome, 'allowed-once');
|
||||
assert.equal(completions, 1);
|
||||
assert.equal(failures, 0);
|
||||
assert.equal(cancellations, 0);
|
||||
await executor.close();
|
||||
});
|
||||
|
||||
test('AI Office Job safely cancels the Harness session when lease renewal fails', async () => {
|
||||
const jobId = 'job-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
const clock = controlledSleep();
|
||||
let sessionStarted = false;
|
||||
let cancellations = 0;
|
||||
let completions = 0;
|
||||
let failures = 0;
|
||||
const transport = {
|
||||
getJob: async () => ({ job: {
|
||||
id: jobId,
|
||||
workspaceAlias: 'office-project',
|
||||
instructionPreset: 'execute',
|
||||
markdown: '# Renewal failure test',
|
||||
} }),
|
||||
acceptJob: async () => ({ leaseToken: 'lease-renew-failure' }),
|
||||
renewJob: async () => { throw new Error('lease renewal unavailable'); },
|
||||
progressJob: async () => ({ ok: true }),
|
||||
completeJob: async () => { completions += 1; return { ok: true }; },
|
||||
failJob: async () => { failures += 1; return { ok: true }; },
|
||||
};
|
||||
const executor = new OfficeJobExecutor({
|
||||
config: {
|
||||
maxConcurrency: 1,
|
||||
workspaces: { 'office-project': '/tmp/office-project' },
|
||||
instructionPresets: { execute: 'Execute carefully.' },
|
||||
},
|
||||
transport,
|
||||
createHarness: () => ({
|
||||
createSession: async () => 'session-renew-failure',
|
||||
ask: async (_sessionId, _prompt, { signal }) => {
|
||||
sessionStarted = true;
|
||||
return pendingUntilAbort(signal);
|
||||
},
|
||||
rpc: async (method, payload) => {
|
||||
assert.equal(method, 'session.cancel');
|
||||
assert.equal(payload.sessionId, 'session-renew-failure');
|
||||
cancellations += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
sleepImpl: clock.sleep,
|
||||
});
|
||||
|
||||
assert.equal(executor.offer(jobId), true);
|
||||
await eventually(() => sessionStarted
|
||||
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
|
||||
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
|
||||
await eventually(() => executor.status.running === 0 && cancellations === 1);
|
||||
assert.equal(completions, 0);
|
||||
assert.equal(failures, 0);
|
||||
await executor.close();
|
||||
});
|
||||
|
||||
test('AI Office job.cancel SSE event stops only the active Harness job', async () => {
|
||||
const jobId = 'job-cccccccccccccccccccccccccccccccc';
|
||||
const clock = controlledSleep();
|
||||
let sessionStarted = false;
|
||||
let cancellations = 0;
|
||||
let completions = 0;
|
||||
let failures = 0;
|
||||
const executor = new OfficeJobExecutor({
|
||||
config: {
|
||||
maxConcurrency: 1,
|
||||
workspaces: { 'office-project': '/tmp/office-project' },
|
||||
instructionPresets: { execute: 'Execute carefully.' },
|
||||
},
|
||||
transport: {
|
||||
getJob: async () => ({ job: {
|
||||
id: jobId,
|
||||
workspaceAlias: 'office-project',
|
||||
instructionPreset: 'execute',
|
||||
markdown: '# Cancellation test',
|
||||
} }),
|
||||
acceptJob: async () => ({ leaseToken: 'lease-cancellation' }),
|
||||
renewJob: async () => ({ ok: true }),
|
||||
progressJob: async () => ({ ok: true }),
|
||||
completeJob: async () => { completions += 1; return { ok: true }; },
|
||||
failJob: async () => { failures += 1; return { ok: true }; },
|
||||
},
|
||||
createHarness: () => ({
|
||||
createSession: async () => 'session-cancellation',
|
||||
ask: async (_sessionId, _prompt, { signal }) => {
|
||||
sessionStarted = true;
|
||||
return pendingUntilAbort(signal);
|
||||
},
|
||||
rpc: async (method, payload) => {
|
||||
assert.equal(method, 'session.cancel');
|
||||
assert.equal(payload.sessionId, 'session-cancellation');
|
||||
cancellations += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
sleepImpl: clock.sleep,
|
||||
});
|
||||
|
||||
assert.equal(executor.offer(jobId), true);
|
||||
await eventually(() => sessionStarted);
|
||||
assert.equal(executor.handleEvent({ type: 'job.cancel', data: { jobId } }), true);
|
||||
await eventually(() => executor.status.running === 0 && cancellations === 1);
|
||||
assert.equal(completions, 0);
|
||||
assert.equal(failures, 0);
|
||||
await executor.close();
|
||||
});
|
||||
|
||||
test('AI Office SSE short connections keep increasing retry backoff', async () => {
|
||||
const clock = controlledSleep();
|
||||
let streamCalls = 0;
|
||||
const jobs = {
|
||||
status: { running: 0 },
|
||||
offer: () => false,
|
||||
handleEvent() {},
|
||||
close: async () => {},
|
||||
};
|
||||
const runtime = new OfficeRuntime({
|
||||
config: config({ heartbeatSeconds: 60 }),
|
||||
token: TOKEN,
|
||||
transport: {
|
||||
heartbeat: async () => ({ ok: true, jobs: [] }),
|
||||
stream: async ({ signal, onOpen }) => {
|
||||
streamCalls += 1;
|
||||
onOpen();
|
||||
if (streamCalls <= 4) throw new Error('short-lived SSE connection');
|
||||
return pendingUntilAbort(signal);
|
||||
},
|
||||
},
|
||||
jobExecutor: jobs,
|
||||
sleepImpl: clock.sleep,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
for (const expected of [1_000, 3_000, 10_000, 30_000]) {
|
||||
await eventually(() => clock.calls.some((call) => call.delay === expected && !call.settled));
|
||||
clock.calls.find((call) => call.delay === expected && !call.settled).resolve();
|
||||
}
|
||||
await eventually(() => streamCalls === 5);
|
||||
assert.deepEqual(
|
||||
clock.calls.filter((call) => call.delay < 60_000).map((call) => call.delay),
|
||||
[1_000, 3_000, 10_000, 30_000],
|
||||
);
|
||||
assert.equal(runtime.status.reconnects, 4);
|
||||
await Promise.race([
|
||||
runtime.stop(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Office Runtime stop timed out')), 250)),
|
||||
]);
|
||||
assert.equal(runtime.status.state, 'idle');
|
||||
});
|
||||
|
||||
test('AI Office resets retry backoff only after a post-open heartbeat', async () => {
|
||||
const clock = controlledSleep();
|
||||
let heartbeatCalls = 0;
|
||||
let streamCalls = 0;
|
||||
let rejectStableStream;
|
||||
const runtime = new OfficeRuntime({
|
||||
config: config({ heartbeatSeconds: 60 }),
|
||||
token: TOKEN,
|
||||
transport: {
|
||||
heartbeat: async () => { heartbeatCalls += 1; return { ok: true, jobs: [] }; },
|
||||
stream: async ({ signal, onOpen }) => {
|
||||
streamCalls += 1;
|
||||
onOpen();
|
||||
if (streamCalls === 1) throw new Error('first short-lived SSE connection');
|
||||
return new Promise((resolve, reject) => {
|
||||
rejectStableStream = reject;
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
},
|
||||
jobExecutor: {
|
||||
status: { running: 0 },
|
||||
offer: () => false,
|
||||
handleEvent() {},
|
||||
close: async () => {},
|
||||
},
|
||||
sleepImpl: clock.sleep,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
await eventually(() => clock.calls.some((call) => call.delay === 1_000 && !call.settled));
|
||||
clock.calls.find((call) => call.delay === 1_000 && !call.settled).resolve();
|
||||
await eventually(() => streamCalls === 2
|
||||
&& clock.calls.some((call) => call.delay === 60_000 && !call.settled));
|
||||
const heartbeatSleep = clock.calls.filter(
|
||||
(call) => call.delay === 60_000 && !call.settled,
|
||||
).at(-1);
|
||||
heartbeatSleep.resolve();
|
||||
await eventually(() => heartbeatCalls === 3
|
||||
&& clock.calls.filter((call) => call.delay === 60_000 && !call.settled).length === 1);
|
||||
rejectStableStream(new Error('stable SSE connection later ended'));
|
||||
await eventually(() => clock.calls.filter(
|
||||
(call) => call.delay === 1_000 && !call.settled,
|
||||
).length === 1);
|
||||
assert.deepEqual(
|
||||
clock.calls.filter((call) => call.delay < 60_000).map((call) => call.delay),
|
||||
[1_000, 1_000],
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
runtime.stop(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Office Runtime stop timed out')), 250)),
|
||||
]);
|
||||
assert.equal(runtime.status.state, 'idle');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ import test from 'node:test';
|
|||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
TelegramAccessSettings,
|
||||
TelegramAccountCard,
|
||||
TelegramSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/telegram/index.js';
|
||||
|
||||
const { act } = TestRenderer;
|
||||
|
||||
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
|
|
@ -38,5 +42,54 @@ test('Telegram account card matches the unified compact card layout', () => {
|
|||
assert.match(markup, />Bot API 长轮询</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.match(markup, />访问设置</);
|
||||
assert.match(markup, /aria-label="Telegram 访问模式"/);
|
||||
assert.match(markup, />兼容模式(默认)</);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
|
||||
test('Telegram access settings edits and saves one bot policy', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
|
||||
},
|
||||
onSave: async (policy) => saved.push(policy),
|
||||
}));
|
||||
});
|
||||
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' });
|
||||
const textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 Telegram User ID',
|
||||
});
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } });
|
||||
});
|
||||
assert.deepEqual(
|
||||
renderer.root.findByProps({ className: 'dtg-accessBadge' }).children,
|
||||
['已生效:兼容模式'],
|
||||
);
|
||||
await act(async () => {
|
||||
await renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
|
||||
});
|
||||
assert.deepEqual(saved, [{
|
||||
accessMode: 'private-allowlist',
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
}]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Telegram access settings warns when safe mode has an empty allowlist', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
accessPolicy: { accessMode: 'private-allowlist', allowedUsers: [] },
|
||||
},
|
||||
onSave() {},
|
||||
}));
|
||||
assert.match(markup, /白名单为空;保存后该机器人会拒绝所有入站消息。/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
|
||||
|
||||
test('Telegram production normalizes and validates private-message allowlists', () => {
|
||||
test('Telegram per-bot policy normalizes and validates private-message allowlists', () => {
|
||||
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
|
||||
assert.deepEqual(
|
||||
normalizeTelegramAllowedUsers([6087707998, '1202499116', '6087707998']),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
TELEGRAM_ACCESS_MODES,
|
||||
TelegramConfigStore,
|
||||
deriveTelegramBotIdentity,
|
||||
normalizeTelegramAccessPolicy,
|
||||
} from '../../../src/channels/telegram/config-store.mjs';
|
||||
import { TelegramController } from '../../../src/channels/telegram/telegram-controller.mjs';
|
||||
import {
|
||||
|
|
@ -187,11 +189,16 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(status.bots[0].bot.name, 'Harness Telegram');
|
||||
assert.equal(status.bots[0].bot.username, 'harness_bot');
|
||||
assert.deepEqual(status.bots[0].accessPolicy, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: [],
|
||||
});
|
||||
const identity = deriveTelegramBotIdentity('123456789');
|
||||
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
|
||||
const persisted = await readFile(configPath, 'utf8');
|
||||
assert.doesNotMatch(persisted, new RegExp(TOKEN));
|
||||
assert.match(persisted, new RegExp(identity.tokenRef));
|
||||
assert.doesNotMatch(persisted, /accessMode|allowedUsers/);
|
||||
|
||||
await controller.reconnectBot(identity.botId);
|
||||
assert.equal(runtimes.length, 2);
|
||||
|
|
@ -203,6 +210,220 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
assert.equal(controller.status().totals.configured, 0);
|
||||
});
|
||||
|
||||
test('Telegram loads legacy bots without an access policy as compatible mode', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-legacy-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const identity = deriveTelegramBotIdentity('123456789');
|
||||
await writeFile(configPath, `${JSON.stringify({
|
||||
version: 1,
|
||||
bots: [{
|
||||
...identity,
|
||||
platformId: '123456789',
|
||||
name: 'Legacy Telegram',
|
||||
username: 'legacy_bot',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
connectedAt: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const store = await new TelegramConfigStore(configPath).load();
|
||||
const saved = store.get(identity.botId);
|
||||
assert.equal(saved.accessMode, undefined);
|
||||
assert.equal(saved.allowedUsers, undefined);
|
||||
assert.deepEqual(normalizeTelegramAccessPolicy(saved), {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram access policy persists per bot, switches freely, and restarts only that bot', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new TelegramConfigStore(configPath).load();
|
||||
const runtimeRecords = [];
|
||||
let inspected = 0;
|
||||
const controller = new TelegramController({
|
||||
credentials: credentials(),
|
||||
configStore,
|
||||
inspectToken: async () => {
|
||||
inspected += 1;
|
||||
return {
|
||||
platformId: inspected === 1 ? '111111111' : inspected === 2 ? '222222222' : '111111111',
|
||||
name: inspected === 2 ? 'Bot B' : 'Bot A',
|
||||
username: inspected === 2 ? 'bot_b' : 'bot_a',
|
||||
};
|
||||
},
|
||||
createRuntime: async ({ botId, config }) => {
|
||||
const record = { botId, config: structuredClone(config), starts: 0, stops: 0 };
|
||||
runtimeRecords.push(record);
|
||||
return {
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: 10,
|
||||
},
|
||||
async start() { record.starts += 1; },
|
||||
async stop() { record.stops += 1; },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await controller.bindCredentials({ token: TOKEN });
|
||||
await controller.bindCredentials({ token: '222222222:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456' });
|
||||
const botA = deriveTelegramBotIdentity('111111111').botId;
|
||||
const botB = deriveTelegramBotIdentity('222222222').botId;
|
||||
assert.deepEqual(controller.status().bots.map((bot) => bot.accessPolicy), [
|
||||
{ accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] },
|
||||
{ accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] },
|
||||
]);
|
||||
|
||||
await controller.setAccessPolicy(botA, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
});
|
||||
assert.equal(runtimeRecords.filter((record) => record.botId === botA).length, 2);
|
||||
assert.equal(runtimeRecords.filter((record) => record.botId === botB).length, 1);
|
||||
assert.equal(runtimeRecords.find((record) => record.botId === botA).stops, 1);
|
||||
assert.equal(runtimeRecords.find((record) => record.botId === botB).stops, 0);
|
||||
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
});
|
||||
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botB).accessPolicy, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: [],
|
||||
});
|
||||
|
||||
await controller.setAccessPolicy(botA, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
});
|
||||
assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.compatible);
|
||||
assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']);
|
||||
await controller.setAccessPolicy(botA, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
});
|
||||
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '1202499116'],
|
||||
});
|
||||
|
||||
await controller.bindCredentials({ token: TOKEN });
|
||||
assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']);
|
||||
assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.privateAllowlist);
|
||||
|
||||
const reloaded = await new TelegramConfigStore(configPath).load();
|
||||
assert.deepEqual(reloaded.get(botA).allowedUsers, ['6087707998', '1202499116']);
|
||||
assert.equal(reloaded.get(botB).accessMode, undefined);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('Telegram access policy rejects invalid modes and user IDs', () => {
|
||||
assert.throws(() => normalizeTelegramAccessPolicy({
|
||||
accessMode: 'allow-everything',
|
||||
allowedUsers: [],
|
||||
}), /accessMode/);
|
||||
assert.throws(() => normalizeTelegramAccessPolicy({
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['0', '-1001', '@username'],
|
||||
}), /invalid Telegram User ID/);
|
||||
});
|
||||
|
||||
test('Telegram policy update is serialized with deletion and cannot restore a deleted bot', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-delete-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load();
|
||||
const credentialStore = credentials();
|
||||
const unsetStarted = deferred();
|
||||
const releaseUnset = deferred();
|
||||
const unset = credentialStore.unset;
|
||||
credentialStore.unset = async (ref) => {
|
||||
unsetStarted.resolve();
|
||||
await releaseUnset.promise;
|
||||
return unset(ref);
|
||||
};
|
||||
const controller = new TelegramController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
inspectToken: async () => ({
|
||||
platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot',
|
||||
}),
|
||||
createRuntime: async () => ({
|
||||
status: { ready: true, connectionState: 'connected', harnessReachable: true },
|
||||
async start() {},
|
||||
async stop() {},
|
||||
}),
|
||||
});
|
||||
await controller.bindCredentials({ token: TOKEN });
|
||||
const botId = deriveTelegramBotIdentity('123456789').botId;
|
||||
|
||||
const deletion = controller.deleteBot(botId);
|
||||
await unsetStarted.promise;
|
||||
const policyUpdate = controller.setAccessPolicy(botId, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998'],
|
||||
});
|
||||
releaseUnset.resolve();
|
||||
|
||||
await deletion;
|
||||
await assert.rejects(policyUpdate, /Unknown Telegram bot/);
|
||||
assert.equal(configStore.get(botId), null);
|
||||
assert.equal(credentialStore.values.size, 0);
|
||||
assert.equal(controller.status().totals.configured, 0);
|
||||
});
|
||||
|
||||
test('Telegram queued policy update cannot persist after controller close begins', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-close-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load();
|
||||
const reconnectStarted = deferred();
|
||||
const releaseReconnect = deferred();
|
||||
let runtimeCount = 0;
|
||||
const controller = new TelegramController({
|
||||
credentials: credentials(),
|
||||
configStore,
|
||||
inspectToken: async () => ({
|
||||
platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot',
|
||||
}),
|
||||
createRuntime: async () => {
|
||||
runtimeCount += 1;
|
||||
const current = runtimeCount;
|
||||
return {
|
||||
status: { ready: true, connectionState: 'connected', harnessReachable: true },
|
||||
async start() {
|
||||
if (current === 2) {
|
||||
reconnectStarted.resolve();
|
||||
await releaseReconnect.promise;
|
||||
}
|
||||
},
|
||||
async stop() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await controller.bindCredentials({ token: TOKEN });
|
||||
const botId = deriveTelegramBotIdentity('123456789').botId;
|
||||
|
||||
const reconnect = controller.reconnectBot(botId);
|
||||
await reconnectStarted.promise;
|
||||
const policyUpdate = controller.setAccessPolicy(botId, {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998'],
|
||||
});
|
||||
const rejectedPolicy = assert.rejects(policyUpdate, /controller is closed/);
|
||||
const closing = controller.close();
|
||||
releaseReconnect.resolve();
|
||||
|
||||
await reconnect;
|
||||
await rejectedPolicy;
|
||||
await closing;
|
||||
assert.equal(configStore.get(botId).accessMode, undefined);
|
||||
assert.equal(configStore.get(botId).allowedUsers, undefined);
|
||||
});
|
||||
|
||||
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
|
||||
const calls = [];
|
||||
const connectionTests = [];
|
||||
|
|
@ -226,6 +447,13 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
}),
|
||||
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
setAccessPolicy: async (botId, policy) => {
|
||||
calls.push({ botId, policy });
|
||||
return {
|
||||
bots: [{ botId, accessPolicy: policy }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
};
|
||||
},
|
||||
};
|
||||
const handler = createTelegramRpcHandler(controller);
|
||||
const result = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN });
|
||||
|
|
@ -266,6 +494,31 @@ test('Telegram RPC accepts only token binding and strips credential internals',
|
|||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
|
||||
const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998', '6087707998'],
|
||||
});
|
||||
assert.equal(access.ok, true);
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
botId: 'telegram_123',
|
||||
policy: {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['6087707998'],
|
||||
},
|
||||
});
|
||||
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['@username'],
|
||||
})).error.code, 'bad-request');
|
||||
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
|
||||
botId: 'telegram_123',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.compatible,
|
||||
allowedUsers: [],
|
||||
extra: true,
|
||||
})).error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('shared token RPC never sends a connection test after reconnect is cancelled', async () => {
|
||||
|
|
@ -278,6 +531,7 @@ test('shared token RPC never sends a connection test after reconnect is cancelle
|
|||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
setAccessPolicy: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
|
||||
|
|
@ -352,12 +606,21 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||
});
|
||||
|
||||
test('Telegram blocks every group and admits only allowlisted private senders', () => {
|
||||
test('Telegram compatible mode preserves old routing and private allowlist mode restricts inbound messages', () => {
|
||||
const allowed = new Set(['6087707998', '1202499116']);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, allowed), false);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, allowed), true);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, allowed), false);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, new Set()), false);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }), true);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }), true);
|
||||
const policy = {
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedPrivateUserIds: allowed,
|
||||
};
|
||||
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, policy), false);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, policy), true);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, policy), false);
|
||||
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, {
|
||||
...policy,
|
||||
allowedPrivateUserIds: new Set(),
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
||||
|
|
@ -514,6 +777,95 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
|
|||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('Telegram runtime enforces the selected bot private allowlist', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
const asked = [];
|
||||
let delivered = false;
|
||||
let nextMessageId = 500;
|
||||
const updates = [
|
||||
{
|
||||
update_id: 0,
|
||||
message: {
|
||||
message_id: 100,
|
||||
chat: { id: -1001, type: 'group' },
|
||||
from: { id: 7, is_bot: false },
|
||||
text: '@HarnessBot group',
|
||||
entities: [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 1,
|
||||
message: {
|
||||
message_id: 101,
|
||||
chat: { id: 7, type: 'private' },
|
||||
from: { id: 7, is_bot: false },
|
||||
text: 'allowed direct',
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 2,
|
||||
message: {
|
||||
message_id: 102,
|
||||
chat: { id: 8, type: 'private' },
|
||||
from: { id: 8, is_bot: false },
|
||||
text: 'denied direct',
|
||||
},
|
||||
},
|
||||
];
|
||||
const fakeApi = {
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
getUpdates: async ({ timeout, signal }) => {
|
||||
if (timeout === 0) return [];
|
||||
if (!delivered) {
|
||||
delivered = true;
|
||||
return updates;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
editMessageText: async () => true,
|
||||
};
|
||||
const runtime = new TelegramRuntime({
|
||||
config: {
|
||||
botId: 'telegram_allowlist',
|
||||
platformId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
|
||||
allowedUsers: ['7'],
|
||||
},
|
||||
token: TOKEN,
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
createSession: async () => 'session-allowlist',
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
return 'done';
|
||||
},
|
||||
},
|
||||
state,
|
||||
createApi: () => fakeApi,
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await bounded((async () => {
|
||||
while (state.cursor() !== 3 || asked.length !== 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
})(), 'Telegram safe-mode updates were not processed');
|
||||
assert.deepEqual(asked, ['allowed direct']);
|
||||
assert.equal(runtime.status.messagesRejected, 2);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue