mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 05:23:21 +08:00
fix: support Harness interactions across IM channels
This commit is contained in:
parent
3e7e1dcdd7
commit
cd8175d341
30 changed files with 5433 additions and 555 deletions
|
|
@ -58,6 +58,30 @@ function memoryState() {
|
|||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function bounded(promise, message, timeoutMs = 1_000) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => {
|
||||
assert.equal(validTelegramToken(TOKEN), true);
|
||||
assert.equal(validTelegramToken('short'), false);
|
||||
|
|
@ -198,6 +222,34 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(groupMessage.kind, 'group');
|
||||
assert.equal(groupMessage.addressed, true);
|
||||
assert.equal(groupMessage.content, 'run this');
|
||||
|
||||
const topicOne = normalizeTelegramUpdate({
|
||||
update_id: 12,
|
||||
message: {
|
||||
message_id: 6,
|
||||
message_thread_id: 100,
|
||||
chat: { id: -1001, type: 'supergroup' },
|
||||
from: { id: 43, is_bot: false },
|
||||
text: '@HarnessBot first topic',
|
||||
entities: [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
const topicTwo = normalizeTelegramUpdate({
|
||||
update_id: 13,
|
||||
message: {
|
||||
message_id: 7,
|
||||
message_thread_id: 200,
|
||||
chat: { id: -1001, type: 'supergroup' },
|
||||
from: { id: 43, is_bot: false },
|
||||
text: '@HarnessBot second topic',
|
||||
entities: [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(topicOne.conversationId, '-1001:100');
|
||||
assert.equal(topicTwo.conversationId, '-1001:200');
|
||||
assert.notEqual(topicOne.conversationId, topicTwo.conversationId);
|
||||
assert.equal(topicOne.replyTarget.messageThreadId, 100);
|
||||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||
});
|
||||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
|
|
@ -272,3 +324,171 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
|
|||
assert.deepEqual(calls[0], { offset: -1, timeout: 0 });
|
||||
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();
|
||||
const questionSent = deferred();
|
||||
const secondPollStarted = deferred();
|
||||
const answerSubmitted = deferred();
|
||||
const releaseTurn = deferred();
|
||||
const finalReplySent = deferred();
|
||||
const pollOffsets = [];
|
||||
const asked = [];
|
||||
let answerUpdateDelivered = false;
|
||||
let originalTurnEnded = false;
|
||||
let nextOutboundMessageId = 500;
|
||||
|
||||
const promptUpdate = {
|
||||
update_id: 10,
|
||||
message: {
|
||||
message_id: 100,
|
||||
chat: { id: 42, type: 'private' },
|
||||
from: { id: 7, is_bot: false },
|
||||
text: '请先询问测试环境',
|
||||
},
|
||||
};
|
||||
const answerUpdate = {
|
||||
update_id: 11,
|
||||
message: {
|
||||
message_id: 101,
|
||||
chat: { id: 42, type: 'private' },
|
||||
from: { id: 7, is_bot: false },
|
||||
text: '2',
|
||||
},
|
||||
};
|
||||
const fakeApi = {
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
getUpdates: async ({ offset, timeout, signal }) => {
|
||||
pollOffsets.push(offset);
|
||||
if (timeout === 0) return [];
|
||||
if (offset === 0) return [promptUpdate];
|
||||
if (offset === 11) {
|
||||
secondPollStarted.resolve(originalTurnEnded);
|
||||
await questionSent.promise;
|
||||
answerUpdateDelivered = true;
|
||||
return [answerUpdate];
|
||||
}
|
||||
assert.equal(offset, 12);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
sendChatAction: async () => true,
|
||||
sendMessage: async ({ text }) => {
|
||||
const messageId = nextOutboundMessageId;
|
||||
nextOutboundMessageId += 1;
|
||||
if (text.includes('请选择测试环境')) questionSent.resolve();
|
||||
return { message_id: messageId };
|
||||
},
|
||||
editMessageText: async ({ text }) => {
|
||||
if (text === '已选择生产环境') finalReplySent.resolve();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
createSession: async () => 'session-runtime-interaction',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asked.push({ sessionId, text });
|
||||
if (text !== '请先询问测试环境') return '不应将答案当成新 prompt';
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'telegram-runtime-question',
|
||||
rpcId: 'telegram-runtime-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
question: '请选择测试环境',
|
||||
options: [{ label: '测试环境' }, { label: '生产环境' }],
|
||||
}],
|
||||
},
|
||||
respond: async (result) => {
|
||||
assert.equal(answerUpdateDelivered, true);
|
||||
assert.equal(originalTurnEnded, false);
|
||||
answerSubmitted.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await Promise.race([
|
||||
answerSubmitted.promise,
|
||||
new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
await releaseTurn.promise;
|
||||
originalTurnEnded = true;
|
||||
return '已选择生产环境';
|
||||
},
|
||||
};
|
||||
const runtime = new TelegramRuntime({
|
||||
config: {
|
||||
botId: 'telegram_interaction',
|
||||
platformId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
},
|
||||
token: TOKEN,
|
||||
harness,
|
||||
state,
|
||||
createApi: () => fakeApi,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
assert.equal(await bounded(
|
||||
secondPollStarted.promise,
|
||||
'poller did not request the answer update while the first turn was active',
|
||||
), false);
|
||||
const submitted = await bounded(
|
||||
answerSubmitted.promise,
|
||||
'the Telegram answer was not submitted through the interaction fast path',
|
||||
);
|
||||
assert.deepEqual(submitted, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-runtime-interaction',
|
||||
answer: {
|
||||
answers: [{ id: 'environment', selected: ['生产环境'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(originalTurnEnded, false);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-runtime-interaction',
|
||||
text: '请先询问测试环境',
|
||||
}]);
|
||||
|
||||
await bounded((async () => {
|
||||
while (state.cursor() !== 12) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
})(), 'Telegram cursor did not advance past the answer update');
|
||||
assert.deepEqual(pollOffsets.slice(0, 4), [-1, 0, 11, 12]);
|
||||
assert.equal(state.hasSeen('10'), true);
|
||||
assert.equal(state.hasSeen('11'), true);
|
||||
|
||||
releaseTurn.resolve();
|
||||
await bounded(finalReplySent.promise, 'the original Harness turn did not finish');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(originalTurnEnded, true);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-runtime-interaction',
|
||||
text: '请先询问测试环境',
|
||||
}]);
|
||||
} finally {
|
||||
releaseTurn.resolve();
|
||||
await runtime.stop();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue