Envio de alertas Telegram desde el servicio (docs/legacy-telegram.md): por alerta, texto Markdown (content + firelink i18n) + ubicacion, como node-red. - types: canal 'telegram', notif.type 'telegram', campo telegramNotified - telegram/message.ts: buildTelegramMessages (firelink es/en/gl + location) - telegram/client.ts: cliente Bot API (fetch), interpretResponse (429 retry_after, 403 blocked) - telegram/rate.ts: throttle por chat (1 msg/s) + computeWaitMs puro - handlers.processTelegramJob: idempotencia (notification_sends canal telegram), modos dry-run/shadow/canary/full, throttle, 429->retry, bloqueo->sub inactiva - poller/queue/index: canal telegram integrado; worker con rate limiter global BullMQ (25/s por bot); cliente por idioma (es/gl->ES, en->EN) - config: bloque telegram (tokens es/en, server, globalPerSec, perChatMinMs) 85 tests en verde, typecheck y build OK. Sin TELEGRAM_TOKEN_* configurados el worker no se activa (todo inerte). Falta: extraer tokens de bot de flows_cred + rollout con el usuario. Los flujos conversacionales de los bots siguen en node-red.
137 lines
6.4 KiB
TypeScript
137 lines
6.4 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { ObjectId } from 'mongodb';
|
|
import type { Repos } from '../src/db.js';
|
|
import type { Config } from '../src/config.js';
|
|
import { buildTelegramMessages } from '../src/telegram/message.js';
|
|
import { computeWaitMs, PerChatThrottle } from '../src/telegram/rate.js';
|
|
import { interpretResponse, type TelegramClient, type TgResult } from '../src/telegram/client.js';
|
|
import { processTelegramJob, type Deps } from '../src/handlers.js';
|
|
import type { NotificationDoc } from '../src/types.js';
|
|
import { aNotif, aUser, freshRepos, silentLog, testCfg } from './helpers.js';
|
|
|
|
describe('buildTelegramMessages', () => {
|
|
it('builds text (content + firelink) and location', () => {
|
|
const notif = aNotif({ type: 'telegram', content: '🔥 Alerta de posible fuego a 5 км de distancia (fuente NASA)', sealed: 'ENC', geo: { type: 'Point', coordinates: [-3.7, 40.4] } });
|
|
const m = buildTelegramMessages(notif, 'es', 'fuegos.comunes.org');
|
|
expect(m.text).toBe('🔥 Alerta de posible fuego a 5 км de distancia (fuente NASA)\n[más información](https://fuegos.comunes.org/fire/ENC)');
|
|
expect(m.parseMode).toBe('Markdown');
|
|
expect(m.latitude).toBe(40.4);
|
|
expect(m.longitude).toBe(-3.7);
|
|
});
|
|
it('uses the english firelink', () => {
|
|
const notif = aNotif({ type: 'telegram', sealed: 'X' });
|
|
expect(buildTelegramMessages(notif, 'en', 'fires.comunes.org').text).toContain('[more information](https://fires.comunes.org/fire/X)');
|
|
});
|
|
});
|
|
|
|
describe('rate limiting', () => {
|
|
it('computeWaitMs respects the per-chat interval', () => {
|
|
expect(computeWaitMs(undefined, 1000, 1000)).toBe(0);
|
|
expect(computeWaitMs(1000, 1500, 1000)).toBe(500);
|
|
expect(computeWaitMs(1000, 2000, 1000)).toBe(0);
|
|
});
|
|
it('PerChatThrottle waits then records', async () => {
|
|
let t = 0;
|
|
const waits: number[] = [];
|
|
const thr = new PerChatThrottle(1000, () => t, async (ms) => { waits.push(ms); t += ms; });
|
|
await thr.acquire(42); // first: no wait
|
|
await thr.acquire(42); // immediately after: waits 1000
|
|
expect(waits).toEqual([1000]);
|
|
});
|
|
});
|
|
|
|
describe('interpretResponse', () => {
|
|
it('ok', () => {
|
|
expect(interpretResponse(200, { ok: true })).toEqual({ ok: true });
|
|
});
|
|
it('429 with retry_after', () => {
|
|
const r = interpretResponse(429, { ok: false, error_code: 429, description: 'Too Many Requests', parameters: { retry_after: 7 } });
|
|
expect(r).toEqual({ ok: false, retryAfterMs: 7000, error: 'Too Many Requests' });
|
|
});
|
|
it('403 marks blocked', () => {
|
|
const r = interpretResponse(403, { ok: false, error_code: 403, description: 'bot was blocked by the user' });
|
|
expect(r).toMatchObject({ ok: false, blocked: true });
|
|
});
|
|
});
|
|
|
|
// ---- handler ----
|
|
function fakeTgClient(result: TgResult = { ok: true }) {
|
|
const calls: Array<{ method: string; chatId: number }> = [];
|
|
const client: TelegramClient = {
|
|
async sendMessage(chatId) { calls.push({ method: 'message', chatId }); return result; },
|
|
async sendLocation(chatId) { calls.push({ method: 'location', chatId }); return result; },
|
|
};
|
|
return { client, calls };
|
|
}
|
|
|
|
let repos: Repos;
|
|
const baseCfg: Config = testCfg({ ownedChannels: ['telegram'] });
|
|
beforeEach(async () => { repos = await freshRepos(); });
|
|
|
|
function tgDeps(clientResult: TgResult, over: Partial<Deps> = {}): { deps: Deps; calls: Array<{ method: string; chatId: number }> } {
|
|
const { client, calls } = fakeTgClient(clientResult);
|
|
const deps: Deps = {
|
|
cfg: baseCfg, repos, log: silentLog,
|
|
telegram: {
|
|
clientFor: () => client,
|
|
throttle: new PerChatThrottle(0, () => 0, async () => {}),
|
|
server: 'fuegos.comunes.org',
|
|
},
|
|
...over,
|
|
};
|
|
return { deps, calls };
|
|
}
|
|
|
|
async function seedTelegramNotif(chatId = 111): Promise<NotificationDoc> {
|
|
const subsId = new ObjectId();
|
|
await repos.subscriptions.insertOne({ _id: subsId, location: { lat: 40.4, lon: -3.7 }, geo: { type: 'Point', coordinates: [-3.7, 40.4] }, distance: 40, owner: 'u1', type: 'telegram', chatId } as never);
|
|
await repos.users.insertOne(aUser({ _id: 'u1', lang: 'es' }));
|
|
const notif = aNotif({ type: 'telegram', userId: 'u1', subsId });
|
|
await repos.notifications.insertOne(notif);
|
|
return notif;
|
|
}
|
|
|
|
describe('processTelegramJob', () => {
|
|
it('sends text + location and marks telegramNotified (full)', async () => {
|
|
const notif = await seedTelegramNotif(111);
|
|
const { deps, calls } = tgDeps({ ok: true });
|
|
await processTelegramJob(deps, notif._id.toHexString());
|
|
expect(calls).toEqual([{ method: 'message', chatId: 111 }, { method: 'location', chatId: 111 }]);
|
|
const after = await repos.notifications.findOne({ _id: notif._id });
|
|
expect(after?.telegramNotified).toBe(true);
|
|
expect(await repos.sends.findOne({ notificationId: notif._id, channel: 'telegram' })).not.toBeNull();
|
|
});
|
|
|
|
it('dry-run does not send', async () => {
|
|
const notif = await seedTelegramNotif();
|
|
const { deps, calls } = tgDeps({ ok: true }, { cfg: { ...baseCfg, mode: 'dry-run' } });
|
|
await processTelegramJob(deps, notif._id.toHexString());
|
|
expect(calls).toHaveLength(0);
|
|
expect((await repos.notifications.findOne({ _id: notif._id }))?.telegramNotified).toBeUndefined();
|
|
});
|
|
|
|
it('is idempotent: does not resend', async () => {
|
|
const notif = await seedTelegramNotif();
|
|
const { deps, calls } = tgDeps({ ok: true });
|
|
await processTelegramJob(deps, notif._id.toHexString());
|
|
await processTelegramJob(deps, notif._id.toHexString()); // guard: already notified
|
|
expect(calls).toHaveLength(2); // only the first run sent (2 msgs)
|
|
});
|
|
|
|
it('marks the sub inactive when the bot is blocked (403), no retry', async () => {
|
|
const notif = await seedTelegramNotif(222);
|
|
const { deps } = tgDeps({ ok: false, blocked: true, error: 'blocked' });
|
|
await processTelegramJob(deps, notif._id.toHexString());
|
|
const sub = await repos.subscriptions.findOne({ chatId: 222 });
|
|
expect((sub as { active?: boolean }).active).toBe(false);
|
|
const send = await repos.sends.findOne({ notificationId: notif._id, channel: 'telegram' });
|
|
expect(send?.status).toBe('dead-token');
|
|
});
|
|
|
|
it('releases reservation and rethrows on 429 (retry)', async () => {
|
|
const notif = await seedTelegramNotif();
|
|
const { deps } = tgDeps({ ok: false, retryAfterMs: 5000, error: 'Too Many Requests' });
|
|
await expect(processTelegramJob(deps, notif._id.toHexString())).rejects.toThrow();
|
|
expect(await repos.sends.countDocuments({ notificationId: notif._id })).toBe(0);
|
|
});
|
|
});
|