Fase 1c: canal Telegram (telegram-worker) — codigo migrado, inerte
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.
This commit is contained in:
parent
a1ed206985
commit
d1fc6798dc
14 changed files with 479 additions and 14 deletions
|
|
@ -46,7 +46,7 @@ describe('poller.runOnce', () => {
|
|||
|
||||
const res = await poller.runOnce();
|
||||
|
||||
expect(res).toEqual({ fcm: 1, email: 1 });
|
||||
expect(res).toEqual({ fcm: 1, email: 1, telegram: 0 });
|
||||
expect(added.fcm).toHaveLength(1);
|
||||
expect(added.email).toHaveLength(1);
|
||||
// claimedBy provenance set
|
||||
|
|
|
|||
137
test/telegram.test.ts
Normal file
137
test/telegram.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue