Files
ApiCore/ApiCore-Backend/src/telegram/telegram-updates.service.ts
T
Nick SzaunigandClaude Opus 5.5 9760ce154f
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s
feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:21:08 +02:00

515 lines
17 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import { PrismaService } from '../prisma/prisma.service';
import { TelegramApiError } from './telegram-api-error';
import {
TelegramClient,
TelegramUpdate,
TelegramUpdateBatch,
} from './telegram-client';
import { TelegramLinkService } from './telegram-link.service';
import type { TelegramTemplateCode } from './telegram-texts';
export const LEASE_SECONDS = 60;
/** Loop interval: also how long a receiver without the lease (or after a 409) waits. */
export const NO_LEASE_WAIT_MS = 30_000;
export const HELP_REPLIES_PER_CHAT = 3;
export const HELP_WINDOW_MS = 600_000;
/** Tunables of the receiver; tests inject their own (limits, clock). */
export interface TelegramUpdatesOptions {
/** Attempts after which a failing update is skipped. */
attempts: number;
/** Time after which a failing update is skipped, in ms of `now()`. */
windowMs: number;
/** Monotonic clock in ms. */
now: () => number;
}
export const TELEGRAM_UPDATES_OPTIONS = Symbol('TELEGRAM_UPDATES_OPTIONS');
/** An update that keeps failing is skipped after this many attempts or this long. */
export const MAX_UPDATE_ATTEMPTS = 5;
export const MAX_UPDATE_RETRY_MS = 600_000;
const MAX_RATE_LIMIT_PAUSE_MS = 3_600_000;
/** Fixed texts for the system page: no ids, no message text. */
export const SKIPPED_UPDATE_TEXT = 'Telegram-Nachricht übersprungen';
const MAX_LAST_ERROR_CHARS = 300;
const START_COMMAND = /^\/([A-Za-z]+)(?:@[A-Za-z0-9_]+)?(?:\s+(\S+))?/;
export type PollOutcome =
'disabled' | 'no-lease' | 'polled' | 'conflict' | 'error';
type Command =
{ name: 'start'; token: string | null } | { name: 'stop' } | { name: 'help' };
/** The lease or the stored offset changed: nothing of this update may stay. */
class LeaseLostError extends Error {
constructor() {
super('Telegram poll lease lost');
this.name = 'LeaseLostError';
}
}
/**
* What may go to a log or lastError: Prisma errors by name, code and model
* (loggableError), every other error by its class name only - a message can
* quote user data.
*/
function describeError(error: unknown): string {
if (
error instanceof Prisma.PrismaClientKnownRequestError ||
error instanceof Prisma.PrismaClientUnknownRequestError ||
error instanceof Prisma.PrismaClientValidationError ||
error instanceof Prisma.PrismaClientInitializationError ||
error instanceof Prisma.PrismaClientRustPanicError
) {
return loggableError(error).message;
}
return error instanceof Error ? error.name : 'unknown error';
}
const CONNECTION_ERROR_CODES = new Set(['P1001', 'P1002', 'P1008', 'P1017']);
const SOCKET_ERROR_CODES = new Set([
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EHOSTUNREACH',
]);
/**
* The database or the network is unreachable (Prisma P1001, P1002, P1008,
* P1017, an initialization error, a refused or timed-out socket, a Telegram
* call without an answer): that says nothing about the update itself, so it
* does not count toward skipping it.
*/
function isConnectionError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientInitializationError) {
return true;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return CONNECTION_ERROR_CODES.has(error.code);
}
if (error instanceof TelegramApiError) {
return error.statusCode === null;
}
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' && SOCKET_ERROR_CODES.has(code);
}
function parseCommand(text: string | null): Command {
const match = START_COMMAND.exec(text?.trim() ?? '');
const name = match?.[1].toLowerCase();
if (name === 'start') {
return { name: 'start', token: match?.[2] ?? null };
}
return name === 'stop' ? { name: 'stop' } : { name: 'help' };
}
/**
* At most HELP_REPLIES_PER_CHAT replies per chat in HELP_WINDOW_MS, counted
* in memory (there is exactly one receiver, the lease sees to that). A
* stranger writing to the bot cannot make it talk without end. Monotonic
* clock, so a clock step changes nothing.
*/
class ReplyLimiter {
private readonly sentAt = new Map<string, number[]>();
constructor(private readonly now: () => number) {}
/** Would one more reply to this chat be within the limit? Counts nothing. */
allows(chatId: bigint): boolean {
this.prune();
return (
(this.sentAt.get(String(chatId))?.length ?? 0) < HELP_REPLIES_PER_CHAT
);
}
/** Counts a reply that was queued and committed. */
record(chatId: bigint): void {
const key = String(chatId);
this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), this.now()]);
}
private prune(): void {
const now = this.now();
for (const [chat, times] of this.sentAt) {
const recent = times.filter((time) => now - time < HELP_WINDOW_MS);
if (recent.length === 0) {
this.sentAt.delete(chat);
} else {
this.sentAt.set(chat, recent);
}
}
}
}
/**
* Receives what people write to the bot (Spec 9a.5, 9a.7): long polling
* getUpdates in the worker, no public endpoint. Exactly one receiver per
* bot: the lease in TelegramPollState (database clock only) says who. Each
* update runs in its own transaction together with the offset advance, which
* is a compare-and-set on lease owner and old offset - a receiver that lost
* the lease changes nothing, so every update takes effect once. Replies are
* queued as deliveries (the sender talks to Telegram, not this class).
* Never logs a token, a link code, a chat id or a message text.
*/
@Injectable()
export class TelegramUpdatesService {
private readonly logger = new Logger(TelegramUpdatesService.name);
/** Names this receiver in the lease. */
private readonly instanceId = randomUUID();
private readonly replies: ReplyLimiter;
private readonly limits: Pick<
TelegramUpdatesOptions,
'attempts' | 'windowMs'
>;
private readonly now: () => number;
private holdsLease = false;
/** Monotonic time until which Telegram asked us to stay away (429). */
private rateLimitedUntil = 0;
/** Failed attempts per update, in memory: a restart gives them a fresh start. */
private readonly failures = new Map<
bigint,
{ attempts: number; firstAt: number }
>();
constructor(
private readonly prisma: PrismaService,
private readonly client: TelegramClient,
private readonly links: TelegramLinkService,
@Optional()
@Inject(TELEGRAM_UPDATES_OPTIONS)
options?: Partial<TelegramUpdatesOptions>,
) {
this.now = options?.now ?? (() => performance.now());
this.limits = {
attempts: options?.attempts ?? MAX_UPDATE_ATTEMPTS,
windowMs: options?.windowMs ?? MAX_UPDATE_RETRY_MS,
};
this.replies = new ReplyLimiter(this.now);
}
/**
* One cycle: take or extend the lease, fetch updates (long poll), handle
* them one by one. 'polled' means the next cycle may start at once.
*/
async pollOnce(signal: AbortSignal): Promise<PollOutcome> {
if (!this.client.isConfigured()) {
return 'disabled';
}
if (signal.aborted || this.now() < this.rateLimitedUntil) {
return 'error';
}
const offset = await this.acquireLease();
if (offset === null) {
return 'no-lease';
}
let batch: TelegramUpdateBatch;
try {
batch = await this.client.getUpdates(offset, signal);
} catch (error: unknown) {
return this.onFetchError(error, signal);
}
if (batch.received > 0 && batch.highestUpdateId === null) {
// An answer nobody can move the offset past would be fetched again at
// once: report it, so the 30 s wait applies.
await this.setLastError('getUpdates answer without update ids');
return 'error';
}
await this.markPolled();
return this.applyBatch(batch, offset, signal);
}
/** The updates in order, then the offset past dropped ones; a clean batch clears lastError. */
private async applyBatch(
batch: TelegramUpdateBatch,
offset: bigint,
signal: AbortSignal,
): Promise<PollOutcome> {
let current = offset;
let skipped = false;
for (const update of batch.updates) {
if (signal.aborted) {
return 'polled';
}
try {
const reply = await this.prisma.$transaction((tx) =>
this.applyUpdate(tx, update, current),
);
// Counted only now: a rolled-back reply was never queued.
if (reply !== null) {
this.replies.record(reply);
}
this.failures.delete(update.updateId);
} catch (error: unknown) {
if (error instanceof LeaseLostError) {
return 'no-lease';
}
if (!(await this.shouldSkip(update, error))) {
return 'error';
}
try {
await this.skipUpdate(update, current);
} catch (skipError: unknown) {
return this.onUpdateError(skipError);
}
skipped = true;
}
current = update.updateId + 1n;
}
const dropped = await this.skipDropped(batch, current, signal);
if (dropped !== 'ok') {
return dropped;
}
this.pruneFailures(current);
if (!skipped) {
await this.clearLastError();
}
return 'polled';
}
/** Forgets updates behind the offset and failures older than the window. */
private pruneFailures(offset: bigint): void {
const now = this.now();
for (const [updateId, state] of this.failures) {
if (updateId < offset || now - state.firstAt >= this.limits.windowMs) {
this.failures.delete(updateId);
}
}
}
/** Dropped updates (no chat, wrong shape): move the offset past them, never fetch them again. */
private async skipDropped(
batch: TelegramUpdateBatch,
current: bigint,
signal: AbortSignal,
): Promise<'ok' | PollOutcome> {
const highest = batch.highestUpdateId;
if (highest === null || highest < current || signal.aborted) {
return 'ok';
}
try {
await this.prisma.$transaction((tx) =>
this.advanceOffsetIn(tx, current, highest + 1n),
);
} catch (error: unknown) {
return this.onUpdateError(error);
}
return 'ok';
}
/** Lease SQL, database clock only. The stored offset, or null without the lease. */
private async acquireLease(): Promise<bigint | null> {
const rows = await this.prisma.$queryRaw<{ updateOffset: bigint }[]>`
UPDATE "TelegramPollState"
SET "leaseOwner" = ${this.instanceId},
"leaseUntil" = now() + make_interval(secs => ${LEASE_SECONDS})
WHERE "id" = 'singleton'
AND ("leaseUntil" IS NULL OR "leaseUntil" < now() OR "leaseOwner" = ${this.instanceId})
RETURNING "updateOffset"`;
this.holdsLease = rows.length > 0;
return rows[0]?.updateOffset ?? null;
}
/**
* Shutdown: the next receiver need not wait for the lease to run out.
* Call it after the loop has stopped; it does nothing without a lease.
*/
async releaseLease(): Promise<void> {
if (!this.holdsLease) {
return;
}
this.holdsLease = false;
try {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "leaseUntil" = now()
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
} catch (error: unknown) {
this.logger.warn(`Telegram lease not released: ${describeError(error)}`);
}
}
private async markPolled(): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastPollAt" = now()
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
private async clearLastError(): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = NULL
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
private async setLastError(text: string): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = ${text.slice(0, MAX_LAST_ERROR_CHARS)}
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
}
/**
* 409: another getUpdates or a webhook; wait. 429: stay away until
* retry_after has passed (at most an hour). The texts are fixed plus the
* error class: nothing from the answer, no token, no URL.
*/
private async onFetchError(
error: unknown,
signal: AbortSignal,
): Promise<PollOutcome> {
if (signal.aborted) {
return 'error';
}
const status = error instanceof TelegramApiError ? error.statusCode : null;
if (status === 429) {
const seconds =
error instanceof TelegramApiError ? error.retryAfterSec : null;
this.rateLimitedUntil =
this.now() +
Math.min(
(seconds ?? NO_LEASE_WAIT_MS / 1_000) * 1_000,
MAX_RATE_LIMIT_PAUSE_MS,
);
}
let label = 'getUpdates failed';
if (status === 409) {
label = 'getUpdates conflict (409)';
} else if (status === 429) {
label = 'getUpdates rate limited (429)';
}
await this.setLastError(`${label}: ${describeError(error)}`);
return status === 409 ? 'conflict' : 'error';
}
private async onUpdateError(error: unknown): Promise<PollOutcome> {
if (error instanceof LeaseLostError) {
return 'no-lease';
}
// The offset stays: the update is tried again in the next cycle.
const text = `update not handled: ${describeError(error)}`;
this.logger.error(`Telegram ${text}`);
await this.setLastError(text);
return 'error';
}
/**
* Counts a failed attempt of an update; true once it failed too often or
* for too long and has to be skipped (otherwise the failure is recorded).
*/
private async shouldSkip(
update: TelegramUpdate,
error: unknown,
): Promise<boolean> {
if (isConnectionError(error)) {
// The database or network is down: the update is not at fault.
await this.onUpdateError(error);
return false;
}
const now = this.now();
const known = this.failures.get(update.updateId);
const state = {
attempts: (known?.attempts ?? 0) + 1,
firstAt: known?.firstAt ?? now,
};
this.failures.set(update.updateId, state);
const exhausted =
state.attempts >= this.limits.attempts ||
now - state.firstAt >= this.limits.windowMs;
if (!exhausted) {
await this.onUpdateError(error);
}
return exhausted;
}
/** Offset past a poison update in its own transaction, so the queue moves on. */
private async skipUpdate(
update: TelegramUpdate,
oldOffset: bigint,
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
await tx.$executeRaw`
UPDATE "TelegramPollState"
SET "lastError" = ${SKIPPED_UPDATE_TEXT}
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
});
this.failures.delete(update.updateId);
this.logger.warn('Telegram update skipped after repeated failures');
}
/** Offset compare-and-set on lease owner and old offset; count 0: lease lost. */
private async advanceOffsetIn(
tx: Prisma.TransactionClient,
oldOffset: bigint,
newOffset: bigint,
): Promise<void> {
const advanced = await tx.$executeRaw`
UPDATE "TelegramPollState"
SET "updateOffset" = ${newOffset}
WHERE "id" = 'singleton'
AND "leaseOwner" = ${this.instanceId}
AND "leaseUntil" > now()
AND "updateOffset" = ${oldOffset}`;
if (advanced === 0) {
throw new LeaseLostError();
}
}
/**
* Offset first (a lost lease ends here), then the effect, all in `tx`.
* Returns the chat whose reply quota must be counted once `tx` commits.
*/
private async applyUpdate(
tx: Prisma.TransactionClient,
update: TelegramUpdate,
oldOffset: bigint,
): Promise<bigint | null> {
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
// Groups and channels: the bot is not for them.
if (update.chatType !== 'private') {
return null;
}
const command = parseCommand(update.text);
if (command.name === 'stop') {
await this.links.stopIn(tx, update.chatId);
return this.reply(tx, update.chatId, 'STOPPED');
}
if (command.name === 'start' && command.token !== null) {
const result = await this.links.linkFromStartIn(
tx,
command.token,
update.chatId,
update.firstName,
);
return this.reply(tx, update.chatId, result);
}
return this.reply(tx, update.chatId, 'HELP');
}
/**
* LINKED always goes out (it needs a valid one-time token, so no
* stranger can trigger it); every other reply counts against the limit.
* Returns the chat to count after the commit, or null.
*/
private async reply(
tx: Prisma.TransactionClient,
chatId: bigint,
code: TelegramTemplateCode,
): Promise<bigint | null> {
if (code === 'LINKED') {
await this.links.queueReplyIn(tx, chatId, code);
return null;
}
if (!this.replies.allows(chatId)) {
return null;
}
await this.links.queueReplyIn(tx, chatId, code);
return chatId;
}
}