chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Unit tests for TelegramBot + truncateForTelegram.
|
||||
*
|
||||
* Strategy:
|
||||
* - TelegramBot is driven by an injected ITelegramClient stub. We script
|
||||
* `getUpdates` to return queued batches and assert that:
|
||||
* - the offset cursor advances correctly,
|
||||
* - replies fire through sendMessage,
|
||||
* - errors trigger backoff via the injected sleep,
|
||||
* - aborted shutdown exits cleanly without sending residual messages,
|
||||
* - invalid-token (401) stops the loop without burning retries.
|
||||
* - The `sleep` injection turns the polling backoff into a counter we can
|
||||
* drain synchronously inside the test loop.
|
||||
*/
|
||||
|
||||
import { TelegramBot } from '../src/integrations/telegram/telegramBot';
|
||||
import {
|
||||
TelegramClientError,
|
||||
truncateForTelegram,
|
||||
type ITelegramClient,
|
||||
type GetUpdatesOptions,
|
||||
type SendMessageOptions,
|
||||
} from '../src/integrations/telegram/telegramClient';
|
||||
import type { TelegramMessage, TelegramUpdate, TelegramUser } from '../src/integrations/telegram/types';
|
||||
|
||||
class StubClient implements ITelegramClient {
|
||||
public sent: SendMessageOptions[] = [];
|
||||
public getUpdatesCalls: GetUpdatesOptions[] = [];
|
||||
private _queue: Array<TelegramUpdate[] | Error> = [];
|
||||
private _onDrain?: () => void;
|
||||
private _waiters: Array<() => void> = [];
|
||||
public failSendOnce: Error | null = null;
|
||||
|
||||
constructor(public meValue: TelegramUser = { id: 1, is_bot: true, first_name: 'TestBot' }) {}
|
||||
|
||||
queueBatch(updates: TelegramUpdate[]) {
|
||||
this._queue.push(updates);
|
||||
this._wakeWaiters();
|
||||
}
|
||||
queueError(err: Error) {
|
||||
this._queue.push(err);
|
||||
this._wakeWaiters();
|
||||
}
|
||||
onceDrained(cb: () => void) { this._onDrain = cb; }
|
||||
|
||||
private _wakeWaiters() {
|
||||
const w = this._waiters.splice(0);
|
||||
for (const fn of w) fn();
|
||||
}
|
||||
|
||||
async getMe(): Promise<TelegramUser> { return this.meValue; }
|
||||
|
||||
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
|
||||
this.getUpdatesCalls.push(opts);
|
||||
if (this._queue.length === 0) {
|
||||
this._onDrain?.();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => reject(new TelegramClientError('aborted', 'aborted'));
|
||||
opts.signal?.addEventListener('abort', onAbort);
|
||||
this._waiters.push(() => {
|
||||
opts.signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
if (this._queue.length === 0) return [];
|
||||
}
|
||||
const next = this._queue.shift()!;
|
||||
if (next instanceof Error) throw next;
|
||||
return next;
|
||||
}
|
||||
|
||||
async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
|
||||
if (this.failSendOnce) {
|
||||
const err = this.failSendOnce;
|
||||
this.failSendOnce = null;
|
||||
throw err;
|
||||
}
|
||||
this.sent.push(opts);
|
||||
return {
|
||||
message_id: 1,
|
||||
date: 0,
|
||||
chat: { id: opts.chatId, type: 'private' },
|
||||
text: opts.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function update(id: number, chatId: number, text: string): TelegramUpdate {
|
||||
return {
|
||||
update_id: id,
|
||||
message: { message_id: id, date: 0, chat: { id: chatId, type: 'private' }, text },
|
||||
};
|
||||
}
|
||||
|
||||
const flush = () => new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
describe('truncateForTelegram', () => {
|
||||
test('passes through short text', () => {
|
||||
expect(truncateForTelegram('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
test('truncates over 4096 chars with ellipsis', () => {
|
||||
const long = 'x'.repeat(5000);
|
||||
const out = truncateForTelegram(long);
|
||||
expect(out.length).toBeLessThanOrEqual(4096);
|
||||
expect(out.endsWith('(truncated)')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles non-string defensively', () => {
|
||||
expect(truncateForTelegram(undefined as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TelegramBot', () => {
|
||||
let client: StubClient;
|
||||
let handled: Array<{ text: string; chatId: number }>;
|
||||
let bot: TelegramBot;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new StubClient();
|
||||
handled = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (bot) await bot.stop();
|
||||
});
|
||||
|
||||
test('start is idempotent', () => {
|
||||
bot = new TelegramBot({ client, handle: async () => null });
|
||||
bot.start();
|
||||
expect(bot.isRunning()).toBe(true);
|
||||
bot.start();
|
||||
expect(bot.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
test('processes updates and advances offset', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async (text, chatId) => { handled.push({ text, chatId }); return `echo: ${text}`; },
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
client.queueBatch([update(101, 999, 'hi'), update(102, 999, 'second')]);
|
||||
client.onceDrained(() => { /* now hanging, safe to stop */ void bot.stop(); });
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 30));
|
||||
await bot.stop();
|
||||
|
||||
expect(handled).toEqual([
|
||||
{ text: 'hi', chatId: 999 },
|
||||
{ text: 'second', chatId: 999 },
|
||||
]);
|
||||
expect(client.sent.map(s => s.text)).toEqual(['echo: hi', 'echo: second']);
|
||||
// Second poll uses offset = 103 (lastId + 1)
|
||||
const offsets = client.getUpdatesCalls.map(c => c.offset);
|
||||
expect(offsets[1]).toBe(103);
|
||||
});
|
||||
|
||||
test('skips reply when handler returns null', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
client.queueBatch([update(1, 1, 'ignored')]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
await bot.stop();
|
||||
|
||||
expect(client.sent).toEqual([]);
|
||||
});
|
||||
|
||||
test('handler exception is converted to a reply (loop survives)', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => { throw new Error('boom'); },
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
client.queueBatch([update(1, 1, 'hi')]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
await bot.stop();
|
||||
|
||||
expect(client.sent).toHaveLength(1);
|
||||
expect(client.sent[0].text).toContain('boom');
|
||||
});
|
||||
|
||||
test('network error triggers backoff and retries', async () => {
|
||||
const sleeps: number[] = [];
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 800,
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
});
|
||||
client.queueError(new TelegramClientError('network', 'temp net'));
|
||||
client.queueError(new TelegramClientError('network', 'temp net'));
|
||||
client.queueBatch([update(1, 1, 'after-recovery')]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 30));
|
||||
await bot.stop();
|
||||
|
||||
// First two failures should produce 100ms then 200ms (exponential, capped at 800).
|
||||
expect(sleeps.length).toBeGreaterThanOrEqual(2);
|
||||
expect(sleeps[0]).toBe(100);
|
||||
expect(sleeps[1]).toBe(200);
|
||||
// Loop continued past errors and eventually saw the success batch.
|
||||
expect(client.getUpdatesCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('401 invalid-token stops loop without retries', async () => {
|
||||
const sleeps: number[] = [];
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
});
|
||||
client.queueError(new TelegramClientError('api', 'unauthorized', 401));
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
// Bot should have stopped itself.
|
||||
expect(bot.isRunning()).toBe(false);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
|
||||
test('aborted exits cleanly without backoff', async () => {
|
||||
const sleeps: number[] = [];
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
});
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
await bot.stop();
|
||||
expect(sleeps).toEqual([]); // no backoff on graceful abort
|
||||
});
|
||||
|
||||
test('reply send failure is logged but loop continues', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => 'reply',
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
client.failSendOnce = new Error('send failed');
|
||||
client.queueBatch([update(1, 1, 'first'), update(2, 1, 'second')]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 30));
|
||||
await bot.stop();
|
||||
|
||||
// First send failed, second still attempted.
|
||||
expect(client.sent).toHaveLength(1);
|
||||
expect(client.sent[0].text).toBe('reply');
|
||||
});
|
||||
|
||||
test('enrollNextChat captures the next message and skips the AI handler', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async (text, chatId) => { handled.push({ text, chatId }); return 'should-not-fire'; },
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
client.queueBatch([
|
||||
{
|
||||
update_id: 50,
|
||||
message: {
|
||||
message_id: 1, date: 0,
|
||||
chat: { id: 777, type: 'private' },
|
||||
from: { id: 777, is_bot: false, first_name: 'Alice', username: 'alice' },
|
||||
text: 'hello',
|
||||
},
|
||||
},
|
||||
]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
bot.start();
|
||||
const captured = await bot.enrollNextChat(5000);
|
||||
await bot.stop();
|
||||
|
||||
expect(captured.chatId).toBe(777);
|
||||
expect(captured.username).toBe('alice');
|
||||
expect(captured.firstName).toBe('Alice');
|
||||
// Normal handler must NOT have fired for the captured update.
|
||||
expect(handled).toEqual([]);
|
||||
// Bot should have sent the enrollment ack (not a real reply).
|
||||
expect(client.sent).toHaveLength(1);
|
||||
expect(client.sent[0].text).toContain('등록');
|
||||
});
|
||||
|
||||
test('enrollNextChat times out cleanly when no message arrives', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
bot.start();
|
||||
await expect(bot.enrollNextChat(50)).rejects.toThrow(/within|received/i);
|
||||
await bot.stop();
|
||||
});
|
||||
|
||||
test('a second enrollNextChat supersedes the first', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
bot.start();
|
||||
const first = bot.enrollNextChat(60_000);
|
||||
// Eat the unhandled rejection by attaching a handler immediately.
|
||||
const firstFailed = first.catch((e) => e.message);
|
||||
const secondP = bot.enrollNextChat(60_000);
|
||||
client.queueBatch([
|
||||
{ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 5, type: 'private' }, text: 'hi' } },
|
||||
]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
const second = await secondP;
|
||||
await bot.stop();
|
||||
expect(second.chatId).toBe(5);
|
||||
await expect(firstFailed).resolves.toMatch(/superseded/i);
|
||||
});
|
||||
|
||||
test('cancelEnrollment rejects the pending promise', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async () => null,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
bot.start();
|
||||
const p = bot.enrollNextChat(60_000);
|
||||
bot.cancelEnrollment();
|
||||
await expect(p).rejects.toThrow(/cancel/i);
|
||||
await bot.stop();
|
||||
});
|
||||
|
||||
test('updates without text or chatId are ignored', async () => {
|
||||
bot = new TelegramBot({
|
||||
client,
|
||||
handle: async (text, chatId) => { handled.push({ text, chatId }); return null; },
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
const malformed: TelegramUpdate = { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 0, type: 'private' } } };
|
||||
const noChat: TelegramUpdate = { update_id: 2, message: { message_id: 2, date: 0, chat: undefined as any, text: 'hi' } };
|
||||
const valid = update(3, 99, 'real');
|
||||
client.queueBatch([malformed, noChat, valid]);
|
||||
client.onceDrained(() => void bot.stop());
|
||||
|
||||
bot.start();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
await bot.stop();
|
||||
|
||||
expect(handled).toEqual([{ text: 'real', chatId: 99 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user