feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
|
||||
describe('TelegramApiError', () => {
|
||||
it('carries method, status, description and retry_after, and a message without a URL', () => {
|
||||
const error = new TelegramApiError(
|
||||
'sendMessage',
|
||||
429,
|
||||
'Too Many Requests',
|
||||
7,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('TelegramApiError');
|
||||
expect(error.method).toBe('sendMessage');
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.description).toBe('Too Many Requests');
|
||||
expect(error.retryAfterSec).toBe(7);
|
||||
expect(error.message).toBe(
|
||||
'Telegram sendMessage failed: Too Many Requests',
|
||||
);
|
||||
expect(loggableError(error).message).not.toMatch(/https?:/);
|
||||
});
|
||||
|
||||
it('allows a network failure without status', () => {
|
||||
const error = new TelegramApiError('getUpdates', null, 'timeout', null);
|
||||
expect(error.statusCode).toBeNull();
|
||||
expect(error.retryAfterSec).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramDescription', () => {
|
||||
it('removes bot URLs, tokens, control characters and caps the length', () => {
|
||||
const token = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
const dirty = `bad https://api.telegram.org/bot${token}/sendMessage \u0007x ${token} ${'y'.repeat(400)}`;
|
||||
const clean = sanitizeTelegramDescription(dirty);
|
||||
|
||||
expect(clean).not.toContain('AAEhBOweik6ad9r');
|
||||
expect(clean).not.toContain('api.telegram.org');
|
||||
expect(clean).not.toContain('\u0007');
|
||||
expect(clean.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('turns a non-string into a fixed text', () => {
|
||||
expect(sanitizeTelegramDescription(undefined)).toBe('unknown error');
|
||||
expect(sanitizeTelegramDescription(42)).toBe('unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTelegramFailure', () => {
|
||||
it('classifies abort and timeout without using the underlying message', () => {
|
||||
const timeout = Object.assign(
|
||||
new Error('boom bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET'),
|
||||
{ name: 'TimeoutError' },
|
||||
);
|
||||
const abort = Object.assign(new Error('x'), { name: 'AbortError' });
|
||||
|
||||
expect(describeTelegramFailure(timeout)).toBe('timeout');
|
||||
expect(describeTelegramFailure(abort)).toBe('aborted');
|
||||
expect(describeTelegramFailure(new TypeError('fetch failed'))).toBe(
|
||||
'network error',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([['text'], [null], [undefined], [42], [{ message: 'x' }]])(
|
||||
'classifies a non-Error rejection (%p) as a network error',
|
||||
(value) => {
|
||||
expect(describeTelegramFailure(value)).toBe('network error');
|
||||
},
|
||||
);
|
||||
|
||||
it('finds a timeout or abort in the cause chain, and never returns its message', () => {
|
||||
const secret = 'bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET';
|
||||
const timeout = Object.assign(new Error(secret), { name: 'TimeoutError' });
|
||||
const wrapped = new TypeError('fetch failed', {
|
||||
cause: new Error('socket', { cause: timeout }),
|
||||
});
|
||||
|
||||
expect(describeTelegramFailure(wrapped)).toBe('timeout');
|
||||
});
|
||||
|
||||
it('survives a cyclic cause chain', () => {
|
||||
const a = new Error('a');
|
||||
const b = new Error('b', { cause: a });
|
||||
(a as { cause?: unknown }).cause = b;
|
||||
expect(describeTelegramFailure(a)).toBe('network error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const MAX_DESCRIPTION_CHARS = 200;
|
||||
const UNKNOWN_DESCRIPTION = 'unknown error';
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
// A request URL carries the bot token ("…/bot<id>:<secret>/<method>"), and a
|
||||
// description could quote one. Both shapes are removed wherever text from
|
||||
// outside ends up in an error.
|
||||
const BOT_URL = /https?:\/\/\S*/gi;
|
||||
const BOT_TOKEN = /\b(?:bot)?\d{5,}:[A-Za-z0-9_-]{20,}/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||||
|
||||
/**
|
||||
* A failed Telegram call. Carries the method and what Telegram answered -
|
||||
* never the request URL, so the bot token cannot reach a log or lastError
|
||||
* through it.
|
||||
*/
|
||||
export class TelegramApiError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly statusCode: number | null,
|
||||
readonly description: string,
|
||||
readonly retryAfterSec: number | null,
|
||||
) {
|
||||
super(`Telegram ${method} failed: ${description}`);
|
||||
this.name = 'TelegramApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Text from Telegram, safe for an error: no URL, no token, no control characters, short. */
|
||||
export function sanitizeTelegramDescription(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return UNKNOWN_DESCRIPTION;
|
||||
}
|
||||
const clean = value
|
||||
.replace(BOT_URL, '')
|
||||
.replace(BOT_TOKEN, '')
|
||||
.replace(CONTROL_CHARACTERS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_DESCRIPTION_CHARS);
|
||||
return clean === '' ? UNKNOWN_DESCRIPTION : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed description for a call that failed before an answer arrived. The
|
||||
* underlying message is dropped on purpose: a fetch error can quote the URL.
|
||||
*/
|
||||
export function describeTelegramFailure(error: unknown): string {
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
// fetch wraps the real reason in `cause`; the depth limit and the visited
|
||||
// set keep a cyclic chain from looping.
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (!(current instanceof Error) || seen.has(current)) {
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (current.name === 'TimeoutError') {
|
||||
return 'timeout';
|
||||
}
|
||||
if (current.name === 'AbortError') {
|
||||
return 'aborted';
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
return 'network error';
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import {
|
||||
sanitizeTelegramText,
|
||||
TELEGRAM_MAX_MESSAGE_CHARS,
|
||||
TelegramClient,
|
||||
} from './telegram-client';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('TelegramClient', () => {
|
||||
let fetchMock: jest.SpyInstance;
|
||||
let client: TelegramClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(globalThis, 'fetch');
|
||||
client = new TelegramClient({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('is configured only with a well-formed token', () => {
|
||||
expect(client.isConfigured()).toBe(true);
|
||||
expect(
|
||||
new TelegramClient({ token: null, botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
new TelegramClient({ token: 'junk', botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the token never appears in TelegramApiError or loggableError', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError(
|
||||
`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`,
|
||||
),
|
||||
);
|
||||
|
||||
const error = await client.sendMessage(1n, 'x').catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
const apiError = error as TelegramApiError;
|
||||
expect(
|
||||
JSON.stringify({
|
||||
...apiError,
|
||||
message: apiError.message,
|
||||
log: loggableError(apiError),
|
||||
}),
|
||||
).not.toContain(TOKEN);
|
||||
expect(apiError.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('the token does not leak through a description returned by Telegram', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: `Unauthorized ${TOKEN}` }, 401),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(`${error.message}${error.description}`).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('sends plain text without parse_mode and with link preview disabled', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(4242n, 'Hallo');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`);
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
chat_id: 4242,
|
||||
text: 'Hallo',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(body).not.toHaveProperty('parse_mode');
|
||||
});
|
||||
|
||||
it('does not follow redirects (the URL carries the token)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(1n, 'x');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('a redirect error becomes a network error without the URL', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new Error(`redirect to https://evil.example/bot${TOKEN}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.description).toBe('network error');
|
||||
expect(JSON.stringify({ ...error, m: error.message })).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: 'Too Many Requests: retry after 12',
|
||||
parameters: { retry_after: 12 },
|
||||
},
|
||||
429,
|
||||
),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.retryAfterSec).toBe(12);
|
||||
expect(error.method).toBe('sendMessage');
|
||||
});
|
||||
|
||||
it('maps 429 without parameters to retryAfterSec null', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: 'Too Many Requests' }, 429),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[12, 12],
|
||||
[0, 1],
|
||||
[-5, 1],
|
||||
[999_999, 3600],
|
||||
['12', null],
|
||||
[1.5, null],
|
||||
[null, null],
|
||||
])('clamps retry_after %p to %p', async (raw, expected) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'x', parameters: { retry_after: raw } },
|
||||
429,
|
||||
),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
retryAfterSec: expected,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a 403 description through unchanged (callers decide)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'Forbidden: bot was blocked by the user' },
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
description: 'Forbidden: bot was blocked by the user',
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an ok:false answer and a non-JSON body as TelegramApiError', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 502 }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an oversized response body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('x'.repeat(300_000), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops reading a streamed body past the cap even without Content-Length', async () => {
|
||||
const chunk = new Uint8Array(100 * 1024).fill(120);
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent++;
|
||||
controller.enqueue(chunk);
|
||||
if (sent >= 50) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
expect(sent).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('classifies a caller abort and a timeout without status', async () => {
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'AbortError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'aborted',
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'TimeoutError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a TelegramApiError when no token is configured, without calling out', async () => {
|
||||
const bare = new TelegramClient({ token: null, botUsername: null });
|
||||
|
||||
await expect(bare.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call out with a malformed token either', async () => {
|
||||
const bad = new TelegramClient({ token: 'junk', botUsername: 'b' });
|
||||
|
||||
await expect(bad.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a chat id that does not fit a JSON number', async () => {
|
||||
await expect(client.sendMessage(2n ** 60n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('getUpdates', () => {
|
||||
it('asks for messages only with a 25 s long poll and parses the updates', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
update_id: 100,
|
||||
message: {
|
||||
text: '/start abc',
|
||||
chat: { id: 555, type: 'private' },
|
||||
from: { first_name: 'Imke' },
|
||||
},
|
||||
},
|
||||
{ update_id: 101, edited_message: {} },
|
||||
{ update_id: 102, message: { chat: { id: -9, type: 'group' } } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const updates = await client.getUpdates(100n);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
offset: 100,
|
||||
timeout: 25,
|
||||
allowed_updates: ['message'],
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
updateId: 100n,
|
||||
chatId: 555n,
|
||||
chatType: 'private',
|
||||
text: '/start abc',
|
||||
firstName: 'Imke',
|
||||
},
|
||||
{
|
||||
updateId: 102n,
|
||||
chatId: -9n,
|
||||
chatType: 'group',
|
||||
text: null,
|
||||
firstName: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing arrived', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
|
||||
await expect(client.getUpdates(0n)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramText', () => {
|
||||
it('strips control characters but keeps line breaks', () => {
|
||||
expect(sanitizeTelegramText('a\u0000b\u001bc\r\nd\te')).toBe('abc\nd e');
|
||||
});
|
||||
|
||||
it('caps the text at the maximum length', () => {
|
||||
const out = sanitizeTelegramText('x'.repeat(2000));
|
||||
expect(out.length).toBe(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts by code point, never inside a surrogate pair', () => {
|
||||
const out = sanitizeTelegramText('😀'.repeat(700));
|
||||
expect([...out]).toHaveLength(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
expect(out).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
|
||||
});
|
||||
|
||||
it('strips bidi overrides, isolates and zero-width characters', () => {
|
||||
const dirty = 'abcdefghijklmno';
|
||||
expect(sanitizeTelegramText(dirty)).toBe('abcdefghijklmno');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
import { TELEGRAM_TOKEN_PATTERN, type TelegramConfig } from './telegram-config';
|
||||
|
||||
export const TELEGRAM_CONFIG = Symbol('TELEGRAM_CONFIG');
|
||||
|
||||
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000;
|
||||
export const TELEGRAM_POLL_TIMEOUT_SEC = 25;
|
||||
export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
|
||||
export const TELEGRAM_MAX_MESSAGE_CHARS = 600;
|
||||
const MIN_RETRY_AFTER_SEC = 1;
|
||||
const MAX_RETRY_AFTER_SEC = 3600;
|
||||
|
||||
const API_BASE = 'https://api.telegram.org';
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const INVALID_RESPONSE = 'invalid response';
|
||||
|
||||
export interface TelegramUpdate {
|
||||
updateId: bigint;
|
||||
chatId: bigint;
|
||||
chatType: string;
|
||||
text: string | null;
|
||||
firstName: string | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
// Zero-width characters, bidi marks, overrides and isolates: invisible, and
|
||||
// they can make a text read differently from what it says.
|
||||
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
/**
|
||||
* Plain text for a message: tabs and line breaks kept as space and newline,
|
||||
* other control characters and invisible/bidi characters removed, at most
|
||||
* 600 characters (cut by code point, never inside a surrogate pair).
|
||||
*/
|
||||
export function sanitizeTelegramText(text: string): string {
|
||||
const characters = [
|
||||
...text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\t/g, ' ')
|
||||
.replace(CONTROL_CHARACTERS, '')
|
||||
.replace(INVISIBLE_CHARACTERS, ''),
|
||||
];
|
||||
return characters.length > TELEGRAM_MAX_MESSAGE_CHARS
|
||||
? `${characters.slice(0, TELEGRAM_MAX_MESSAGE_CHARS - 1).join('')}…`
|
||||
: characters.join('');
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): JsonObject | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): bigint | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value)
|
||||
? BigInt(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Reads the body up to MAX_RESPONSE_BYTES; null if it is larger or unreadable. */
|
||||
async function readLimitedText(response: Response): Promise<string | null> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return '';
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/** parameters.retry_after as whole seconds clamped to 1..3600; null if absent or not an integer. */
|
||||
function retryAfterOf(parsed: JsonObject): number | null {
|
||||
const seconds = asInteger(asObject(parsed.parameters)?.retry_after);
|
||||
if (seconds === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_RETRY_AFTER_SEC,
|
||||
Math.max(MIN_RETRY_AFTER_SEC, Number(seconds)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseUpdate(raw: unknown): TelegramUpdate | null {
|
||||
const update = asObject(raw);
|
||||
const updateId = asInteger(update?.update_id);
|
||||
const message = asObject(update?.message);
|
||||
const chat = asObject(message?.chat);
|
||||
const chatId = asInteger(chat?.id);
|
||||
const chatType = asString(chat?.type);
|
||||
if (updateId === null || chatId === null || chatType === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
updateId,
|
||||
chatId,
|
||||
chatType,
|
||||
text: asString(message?.text),
|
||||
firstName: asString(asObject(message?.from)?.first_name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram Bot API client. Used by the worker loops only (never from a
|
||||
* request, handler or job; see no-network-outside-loops.spec). The request
|
||||
* URL contains the token and stays inside this class: every failure becomes
|
||||
* a TelegramApiError that carries no URL, and text taken from the answer is
|
||||
* sanitized.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramClient {
|
||||
constructor(
|
||||
@Inject(TELEGRAM_CONFIG) private readonly config: TelegramConfig,
|
||||
) {}
|
||||
|
||||
/** Worker only: a token is present and well-formed. */
|
||||
isConfigured(): boolean {
|
||||
return (
|
||||
this.config.token !== null &&
|
||||
TELEGRAM_TOKEN_PATTERN.test(this.config.token)
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text, no parse_mode, link preview off. Throws TelegramApiError. */
|
||||
async sendMessage(
|
||||
chatId: bigint,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await this.call(
|
||||
'sendMessage',
|
||||
{
|
||||
chat_id: this.jsonNumber('sendMessage', chatId),
|
||||
text: sanitizeTelegramText(text),
|
||||
link_preview_options: { is_disabled: true },
|
||||
},
|
||||
TELEGRAM_SEND_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Long poll for messages from `offset` on. Throws TelegramApiError. */
|
||||
async getUpdates(
|
||||
offset: bigint,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TelegramUpdate[]> {
|
||||
const result = await this.call(
|
||||
'getUpdates',
|
||||
{
|
||||
offset: this.jsonNumber('getUpdates', offset),
|
||||
timeout: TELEGRAM_POLL_TIMEOUT_SEC,
|
||||
allowed_updates: ['message'],
|
||||
},
|
||||
TELEGRAM_POLL_FETCH_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
|
||||
}
|
||||
return result
|
||||
.map(parseUpdate)
|
||||
.filter((update): update is TelegramUpdate => update !== null);
|
||||
}
|
||||
|
||||
private jsonNumber(method: string, value: bigint): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number)) {
|
||||
throw new TelegramApiError(method, null, 'id out of range', null);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private async call(
|
||||
method: string,
|
||||
body: JsonObject,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const { token } = this.config;
|
||||
if (token === null || !this.isConfigured()) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
'bot token not configured',
|
||||
null,
|
||||
);
|
||||
}
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
let response: Response;
|
||||
let text: string | null;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}/bot${token}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// The URL carries the token: never follow it somewhere else.
|
||||
redirect: 'error',
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
text = await readLimitedText(response);
|
||||
} catch (error) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
describeTelegramFailure(error),
|
||||
null,
|
||||
);
|
||||
}
|
||||
return this.interpret(method, response.status, text);
|
||||
}
|
||||
|
||||
/** Reads only ok, description, parameters.retry_after and result. */
|
||||
private interpret(
|
||||
method: string,
|
||||
status: number,
|
||||
text: string | null,
|
||||
): unknown {
|
||||
let parsed: JsonObject | null = null;
|
||||
try {
|
||||
parsed = text === null ? null : asObject(JSON.parse(text));
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed === null) {
|
||||
throw new TelegramApiError(method, status, INVALID_RESPONSE, null);
|
||||
}
|
||||
if (status >= 200 && status < 300 && parsed.ok === true) {
|
||||
return parsed.result;
|
||||
}
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
status,
|
||||
sanitizeTelegramDescription(parsed.description),
|
||||
retryAfterOf(parsed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
assertTelegramToken,
|
||||
telegramBotAvailable,
|
||||
telegramConfig,
|
||||
} from './telegram-config';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
describe('telegramConfig', () => {
|
||||
it('reads token and bot username, trimmed', () => {
|
||||
expect(
|
||||
telegramConfig({
|
||||
TELEGRAM_BOT_TOKEN: ` ${TOKEN} `,
|
||||
TELEGRAM_BOT_USERNAME: ' apicore_bot ',
|
||||
}),
|
||||
).toEqual({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
it('maps empty and missing values to null', () => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_TOKEN: ' ', TELEGRAM_BOT_USERNAME: '' }),
|
||||
).toEqual({ token: null, botUsername: null });
|
||||
expect(telegramConfig({})).toEqual({ token: null, botUsername: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramBotAvailable', () => {
|
||||
it('is true with the username set and the token unset (API pod)', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: 'apicore_bot' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false with an empty or blank username, even with a token', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: ' ' })).toBe(false);
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_TOKEN: TOKEN })).toBe(false);
|
||||
expect(telegramBotAvailable({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramConfig bot username', () => {
|
||||
it.each([
|
||||
['@apicore_bot', 'apicore_bot'],
|
||||
['ApiCore_Bot', 'ApiCore_Bot'],
|
||||
['a'.repeat(32), 'a'.repeat(32)],
|
||||
])('accepts %s', (raw, expected) => {
|
||||
expect(telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'abcd',
|
||||
'1abcde',
|
||||
'a'.repeat(33),
|
||||
'api core bot',
|
||||
'bot/../x',
|
||||
'@@apicore_bot',
|
||||
'https://t.me/x',
|
||||
'_apicore',
|
||||
])('treats %s as unset', (raw) => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername,
|
||||
).toBeNull();
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: raw })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertTelegramToken', () => {
|
||||
const production = { NODE_ENV: 'production' };
|
||||
let logger: { warn: jest.Mock; error: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
logger = { warn: jest.fn(), error: jest.fn() };
|
||||
});
|
||||
|
||||
it('accepts a missing token in every role (Telegram is optional)', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() => assertTelegramToken(role, production, logger)).not.toThrow();
|
||||
}
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a well-formed token in the worker without a log', () => {
|
||||
assertTelegramToken(
|
||||
'worker',
|
||||
{
|
||||
...production,
|
||||
TELEGRAM_BOT_TOKEN: TOKEN,
|
||||
TELEGRAM_BOT_USERNAME: 'apicore_bot',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['worker', 'not-a-token-SECRETPART', {}],
|
||||
['all', 'not-a-token-SECRETPART', {}],
|
||||
['worker', 'change-me-SECRETPART', production],
|
||||
] as const)(
|
||||
'the %s never crashes on a malformed token (%s): logs an error without the value',
|
||||
(role, bad, extra) => {
|
||||
expect(() =>
|
||||
assertTelegramToken(
|
||||
role,
|
||||
{ ...extra, TELEGRAM_BOT_TOKEN: bad },
|
||||
logger,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const logged = (logger.error.mock.calls as [string][])[0][0];
|
||||
expect(logged).toMatch(/TELEGRAM_BOT_TOKEN/);
|
||||
expect(logged).toMatch(/disabled/);
|
||||
expect(logged).not.toContain('SECRETPART');
|
||||
},
|
||||
);
|
||||
|
||||
it('warns about an invalid bot username in every role', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_USERNAME: 'no good' }, logger);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect((logger.warn.mock.calls as [string][])[0][0]).toMatch(
|
||||
/TELEGRAM_BOT_USERNAME/,
|
||||
);
|
||||
});
|
||||
|
||||
it('the API pod and jobs ignore the token entirely (they never have one)', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
assertTelegramToken('job', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ApicoreRole } from '../runtime/role';
|
||||
|
||||
/** Bot token format issued by BotFather: "<bot id>:<secret>". */
|
||||
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||
/** Bot usernames: 5-32 characters, letters, digits, underscore, starting with a letter. */
|
||||
const BOT_USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
|
||||
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string | null;
|
||||
botUsername: string | null;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const text = value?.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/** The configured bot name without a leading "@"; null if unset or not a valid username. */
|
||||
function validBotUsername(raw: string | undefined): string | null {
|
||||
const name = trimmed(raw)?.replace(/^@/, '') ?? null;
|
||||
return name !== null && BOT_USERNAME_PATTERN.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token and bot name from the environment. Empty values count as unset, and
|
||||
* so does a bot name that is not a valid username (it goes into a link).
|
||||
*/
|
||||
export function telegramConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): TelegramConfig {
|
||||
return {
|
||||
token: trimmed(env.TELEGRAM_BOT_TOKEN),
|
||||
botUsername: validBotUsername(env.TELEGRAM_BOT_USERNAME),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Telegram is set up" as the API sees it: the bot name is known. Never
|
||||
* reads the token - the API pod does not have it (Secret apicore-telegram
|
||||
* goes to the worker only). Whether updates really arrive shows
|
||||
* TelegramPollState.
|
||||
*/
|
||||
export function telegramBotAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return telegramConfig(env).botUsername !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup check. Telegram is optional and a bad setting never stops a pod:
|
||||
* a bot username that is not valid is ignored with a warning (every role);
|
||||
* in the worker, a token that is malformed - or, in production, an example
|
||||
* placeholder - is logged as an error and Telegram stays off (the client
|
||||
* refuses to call out with it). Logs name the variable, never its value.
|
||||
* API and job pods never hold the token and skip its check.
|
||||
*/
|
||||
export function assertTelegramToken(
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
logger: Pick<Logger, 'warn' | 'error'> = new Logger('TelegramStartup'),
|
||||
): void {
|
||||
const { token, botUsername } = telegramConfig(env);
|
||||
if (botUsername === null && trimmed(env.TELEGRAM_BOT_USERNAME) !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is not a valid bot username (5-32 letters, digits or underscores): ignored, users cannot connect.',
|
||||
);
|
||||
}
|
||||
if (!WORKER_ROLES.includes(role)) {
|
||||
return;
|
||||
}
|
||||
if (token === null) {
|
||||
if (botUsername !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is set but TELEGRAM_BOT_TOKEN is not: Telegram delivery and receiving stay disabled.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && PLACEHOLDER_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is still set to an example placeholder: Telegram stays disabled - set the real token from BotFather or remove it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!TELEGRAM_TOKEN_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is malformed (expected "<bot id>:<secret>" as issued by BotFather): Telegram stays disabled.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (botUsername === null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is not: users cannot connect, replies still work.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
|
||||
|
||||
/**
|
||||
* The Bot API client for the worker's Telegram loops. Reads the token from
|
||||
* the environment once; without it the client reports isConfigured() false
|
||||
* and the loops do not run.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
|
||||
TelegramClient,
|
||||
],
|
||||
exports: [TelegramClient],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
Reference in new issue
Block a user