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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 20:00:46 +02:00
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 = 'a​b‌c‍d‎e‏f‪g‫h‬i‭j‮k⁦l⁧m⁨n⁩o';
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 {}