feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s

- 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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 01:21:08 +02:00
1 parent fab7f7e12f
commit 9760ce154f
73 files changed
+3633 -269

No files matched your search

@@ -199,14 +199,14 @@ describe('NotificationPreferencesService', () => {
}
});
it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', async () => {
it('NotificationSettingsView.telegram.botNameSet is true with the username set and no token in the API process', async () => {
process.env.TELEGRAM_BOT_USERNAME = '@apicore_test_bot';
delete process.env.TELEGRAM_BOT_TOKEN;
const view = await service.toView(stored as never);
expect(view.telegram).toEqual({
configured: true,
botNameSet: true,
botUsername: 'apicore_test_bot',
linked: false,
firstName: null,
@@ -223,7 +223,7 @@ describe('NotificationPreferencesService', () => {
const view = await service.toView(stored as never);
expect(view.telegram.configured).toBe(false);
expect(view.telegram.botNameSet).toBe(false);
expect(view.telegram.botUsername).toBeNull();
});
@@ -19,11 +19,11 @@ const QUIET_PAIR_MESSAGE =
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
/**
* Telegram as the API sees it. "configured" means the bot name is known
* Telegram as the API sees it. "botNameSet" means the bot name is known
* (the API pod never holds the bot token); the chat id never leaves the server.
*/
export interface TelegramStatusView {
configured: boolean;
botNameSet: boolean;
botUsername: string | null;
linked: boolean;
firstName: string | null;
@@ -48,7 +48,7 @@ function toTelegramStatus(
): TelegramStatusView {
const { botUsername } = telegramConfig();
return {
configured: botUsername !== null,
botNameSet: botUsername !== null,
botUsername,
linked: link !== null,
firstName: link?.telegramFirstName ?? null,
@@ -329,6 +329,7 @@ describe('TelegramClient', () => {
await expect(client.getUpdates(7n)).resolves.toEqual({
updates: [],
highestUpdateId: 9n,
received: 2,
});
});
@@ -337,6 +338,7 @@ describe('TelegramClient', () => {
await expect(client.getUpdates(0n)).resolves.toEqual({
updates: [],
highestUpdateId: null,
received: 0,
});
});
});
@@ -36,6 +36,8 @@ export interface TelegramUpdate {
export interface TelegramUpdateBatch {
updates: TelegramUpdate[];
highestUpdateId: bigint | null;
/** How many entries the answer held, parsed or not. */
received: number;
}
// eslint-disable-next-line no-control-regex
@@ -207,6 +209,7 @@ export class TelegramClient {
.map(parseUpdate)
.filter((update): update is TelegramUpdate => update !== null),
highestUpdateId: highestRawUpdateId(result),
received: result.length,
};
}
@@ -0,0 +1,66 @@
import type { Prisma } from '../../generated/prisma';
import { removeLinkIn } from './telegram-link-removal';
describe('removeLinkIn', () => {
let tx: {
telegramLink: { deleteMany: jest.Mock };
notificationPreference: { updateMany: jest.Mock };
notificationDelivery: { updateMany: jest.Mock };
};
beforeEach(() => {
tx = {
telegramLink: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
notificationDelivery: {
updateMany: jest.fn().mockResolvedValue({ count: 3 }),
},
};
});
const run = (reason: 'unlinked' | 'chat unavailable', chatId?: bigint) =>
removeLinkIn(
tx as unknown as Prisma.TransactionClient,
'u1',
reason,
chatId,
);
it('deletes the link, switches Telegram off and skips the waiting deliveries without a claim', async () => {
await expect(run('unlinked')).resolves.toBe(true);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: 'unlinked' },
});
});
it('with a chat id only a link that still points to that chat goes', async () => {
await run('chat unavailable', 42n);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1', chatId: 42n },
});
});
it('changes nothing else when there was no such link', async () => {
tx.telegramLink.deleteMany.mockResolvedValue({ count: 0 });
await expect(run('chat unavailable', 42n)).resolves.toBe(false);
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
expect(tx.notificationDelivery.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import type { Prisma } from '../../generated/prisma';
/** Why waiting deliveries were dropped; lands in lastError, so no personal data. */
export type LinkRemovalReason = 'unlinked' | 'chat unavailable' | 'relinked';
/**
* Drops what is still waiting for the user's Telegram chat (PENDING and
* PROCESSING): after a disconnect or a move to another chat it must never
* reach a chat linked later. claimedAt is cleared like every settle does, so
* a sender that still holds a claim changes nothing afterwards.
*/
export async function skipPendingDeliveriesIn(
tx: Prisma.TransactionClient,
userId: string,
reason: LinkRemovalReason,
): Promise<void> {
await tx.notificationDelivery.updateMany({
where: {
userId,
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: reason },
});
}
/**
* The one way a Telegram connection ends (unlink, /stop, chat taken by
* another user, bot blocked or chat gone): the link goes, Telegram is off,
* the waiting deliveries are skipped. Call it with the preference row of the
* user locked. `onlyChatId` limits it to a link that still points to that
* chat (a newer link to another chat stays). Resolves to whether a link was
* removed; when none was, nothing else changes.
*/
export async function removeLinkIn(
tx: Prisma.TransactionClient,
userId: string,
reason: LinkRemovalReason,
onlyChatId?: bigint,
): Promise<boolean> {
const { count } = await tx.telegramLink.deleteMany({
where:
onlyChatId === undefined ? { userId } : { userId, chatId: onlyChatId },
});
if (count === 0) {
return false;
}
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
await skipPendingDeliveriesIn(tx, userId, reason);
return true;
}
@@ -293,7 +293,7 @@ describe('TelegramLinkService', () => {
'NotificationPreference',
expect.arrayContaining(['u1', 'u2']),
);
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
@@ -310,9 +310,37 @@ describe('TelegramLinkService', () => {
/^Telegram verbunden mit „Ben“/,
);
// The old link goes before the new one: the chat id is unique.
expect(tx.telegramLink.delete.mock.invocationCallOrder[0]).toBeLessThan(
tx.telegramLink.create.mock.invocationCallOrder[0],
);
expect(
tx.telegramLink.deleteMany.mock.invocationCallOrder[0],
).toBeLessThan(tx.telegramLink.create.mock.invocationCallOrder[0]);
});
it('replacing the old chat of the user by another chat skips the old backlog, the same chat again does not', async () => {
validToken();
const OLD_CHAT = CHAT + 1n;
const ownLink = (chat: bigint) =>
tx.telegramLink.findUnique.mockImplementation(
({ where }: { where: { chatId?: bigint; userId?: string } }) =>
Promise.resolve(
where.userId === 'u1' ? { userId: 'u1', chatId: chat } : null,
),
);
ownLink(OLD_CHAT);
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', claimedAt: null, lastError: 'relinked' },
});
tx.notificationDelivery.updateMany.mockClear();
ownLink(CHAT);
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
expect(tx.notificationDelivery.updateMany).not.toHaveBeenCalled();
});
it('reads the link of the chat before and after taking the locks and refuses a chat that changed owner', async () => {
@@ -362,7 +390,7 @@ describe('TelegramLinkService', () => {
'NotificationPreference',
['u1'],
);
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
@@ -374,7 +402,7 @@ describe('TelegramLinkService', () => {
it('stopIn for an unknown chat changes nothing', async () => {
await service.stopIn(tx as never, CHAT);
expect(tx.telegramLink.delete).not.toHaveBeenCalled();
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
expect(lockRowsForUpdate).not.toHaveBeenCalled();
});
@@ -393,7 +421,7 @@ describe('TelegramLinkService', () => {
'NotificationPreference',
['u1'],
);
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
@@ -421,14 +449,14 @@ describe('TelegramLinkService', () => {
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', lastError: 'unlinked' },
data: { status: 'SKIPPED', claimedAt: null, lastError: 'unlinked' },
});
});
it('unlink without a link is a no-op', async () => {
await service.unlink('u1');
expect(tx.telegramLink.delete).not.toHaveBeenCalled();
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
expect(tx.notificationDelivery.create).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,7 @@ import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { assertTransactionClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { removeLinkIn, skipPendingDeliveriesIn } from './telegram-link-removal';
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
import { NotificationsService } from '../notifications/notifications.service';
import { telegramConfig } from './telegram-config';
@@ -119,7 +120,7 @@ export class TelegramLinkService {
if (!link) {
return;
}
await this.removeLinkIn(tx, link.userId);
await removeLinkIn(tx, link.userId, 'unlinked');
await this.queueReplyIn(tx, link.chatId, 'STOPPED');
});
}
@@ -175,7 +176,7 @@ export class TelegramLinkService {
return 'LINK_INVALID';
}
if (owner && owner.userId !== userId) {
await this.removeLinkIn(tx, owner.userId);
await removeLinkIn(tx, owner.userId, 'unlinked');
await this.notifications.record(
{
userId: owner.userId,
@@ -188,7 +189,15 @@ export class TelegramLinkService {
);
}
const name = sanitizeFirstName(firstName);
// The user's own earlier chat is replaced; linkedAt starts again.
// The user's own earlier chat is replaced; linkedAt starts again. What
// waited for another chat is dropped; the same chat again keeps it.
const earlier = await tx.telegramLink.findUnique({
where: { userId },
select: { chatId: true },
});
if (earlier && earlier.chatId !== chatId) {
await skipPendingDeliveriesIn(tx, userId, 'relinked');
}
await tx.telegramLink.deleteMany({ where: { userId } });
await tx.telegramLink.create({
data: { userId, chatId, telegramFirstName: name },
@@ -227,7 +236,7 @@ export class TelegramLinkService {
select: { userId: true },
});
if (link) {
await this.removeLinkIn(tx, link.userId);
await removeLinkIn(tx, link.userId, 'unlinked');
}
}
@@ -244,26 +253,6 @@ export class TelegramLinkService {
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
}
private async removeLinkIn(
tx: Prisma.TransactionClient,
userId: string,
): Promise<void> {
await tx.telegramLink.delete({ where: { userId } });
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
// Whatever was queued for the old chat must never reach a chat linked later.
await tx.notificationDelivery.updateMany({
where: {
userId,
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: { status: 'SKIPPED', lastError: 'unlinked' },
});
}
/** Rows must exist, then one statement locks them all in key order. */
private async lockPreferences(
tx: Prisma.TransactionClient,
@@ -82,6 +82,7 @@ describe('TelegramSender', () => {
let tx: {
telegramLink: { deleteMany: jest.Mock };
notificationPreference: { updateMany: jest.Mock };
notificationDelivery: { updateMany: jest.Mock };
};
let client: {
sendMessage: jest.Mock<Promise<void>, [bigint, string, AbortSignal?]>;
@@ -115,6 +116,9 @@ describe('TelegramSender', () => {
notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
notificationDelivery: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
};
prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) =>
Promise.resolve(work(tx)),
@@ -483,6 +487,19 @@ describe('TelegramSender', () => {
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
// The backlog of the dead chat is dropped with the link.
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
where: {
userId: 'u1',
channel: 'TELEGRAM',
status: { in: ['PENDING', 'PROCESSING'] },
},
data: {
status: 'SKIPPED',
claimedAt: null,
lastError: 'chat unavailable',
},
});
expect(notifications.record).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'u1',
@@ -15,6 +15,7 @@ import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { removeLinkIn } from './telegram-link-removal';
import { TelegramApiError } from './telegram-api-error';
import { TelegramClient } from './telegram-client';
import {
@@ -504,14 +505,9 @@ export class TelegramSender {
}
await this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'NotificationPreference', userId);
const { count } = await tx.telegramLink.deleteMany({
where: { userId, chatId },
});
if (count === 1) {
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
// Link, switch and the waiting backlog end together (only if the link
// still points to this chat).
if (await removeLinkIn(tx, userId, 'chat unavailable', chatId)) {
await this.notifications.record(
{
userId,
@@ -1,4 +1,5 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import type { PrismaService } from '../prisma/prisma.service';
import { TelegramApiError } from './telegram-api-error';
import type {
@@ -80,6 +81,9 @@ describe('TelegramUpdatesService', () => {
const ids = updates.map((entry) => entry.updateId);
const batch: TelegramUpdateBatch = {
updates,
received:
updates.length +
(highest !== undefined && updates.length === 0 ? 1 : 0),
highestUpdateId:
highest !== undefined
? BigInt(highest)
@@ -113,7 +117,7 @@ describe('TelegramUpdatesService', () => {
isConfigured: jest.fn().mockReturnValue(true),
getUpdates: jest
.fn()
.mockResolvedValue({ updates: [], highestUpdateId: null }),
.mockResolvedValue({ updates: [], highestUpdateId: null, received: 0 }),
};
links = {
linkFromStartIn: jest.fn().mockResolvedValue('LINKED'),
@@ -354,7 +358,7 @@ describe('TelegramUpdatesService', () => {
);
});
it('an aborted poll (shutdown) records no error and hands the lease back', async () => {
it('an aborted poll (shutdown) records no error', async () => {
const controller = new AbortController();
client.getUpdates.mockImplementation(() => {
controller.abort();
@@ -365,22 +369,46 @@ describe('TelegramUpdatesService', () => {
await expect(service.pollOnce(controller.signal)).resolves.toBe('error');
// Only the release: no lastError, no lastPollAt.
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
const release = sqlOf(callOf(prisma.$executeRaw, 0));
expect(release.text).toMatch(/SET "leaseUntil" = now\(\)/);
expect(release.text).toMatch(/"leaseOwner" = /);
expect(release.text).not.toMatch(/lastError/);
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
it('does not touch the lease on shutdown when it never held it', async () => {
leaseGranted(null);
const controller = new AbortController();
controller.abort();
describe('releaseLease', () => {
it('hands the lease back with the database clock, once, also after a quiet wait', async () => {
await service.pollOnce(signal);
prisma.$executeRaw.mockClear();
await service.pollOnce(controller.signal);
await service.releaseLease();
await service.releaseLease();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
const release = sqlOf(callOf(prisma.$executeRaw, 0));
expect(release.text).toMatch(/SET "leaseUntil" = now\(\)/);
expect(release.text).toMatch(/"leaseOwner" = /);
expect(release.text).not.toMatch(/lastError/);
});
it('does nothing when the lease was never held or not granted', async () => {
await service.releaseLease();
leaseGranted(null);
await service.pollOnce(signal);
await service.releaseLease();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
it('answers error and keeps the offset when the answer has entries but no update ids', async () => {
client.getUpdates.mockResolvedValue({
updates: [],
highestUpdateId: null,
received: 2,
});
await expect(service.pollOnce(signal)).resolves.toBe('error');
expect(prisma.$transaction).not.toHaveBeenCalled();
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
expect(stored.values).toContain('getUpdates answer without update ids');
});
describe('poison updates', () => {
@@ -460,6 +488,102 @@ describe('TelegramUpdatesService', () => {
});
});
describe('failure bookkeeping', () => {
const make = (options: {
attempts?: number;
windowMs?: number;
now?: () => number;
}) =>
new TelegramUpdatesService(
prisma as unknown as PrismaService,
client as unknown as TelegramClient,
links as unknown as TelegramLinkService,
options,
);
it('forgets failures of updates behind the offset and failures older than the window', async () => {
let clock = 0;
const svc = make({ attempts: 2, windowMs: 1_000, now: () => clock });
giveUpdates([update(10, '/stop')]);
links.stopIn.mockRejectedValue(new Error('boom'));
await expect(svc.pollOnce(signal)).resolves.toBe('error');
// A clean batch behind the old update prunes it ...
links.stopIn.mockResolvedValue(undefined);
leaseGranted(20n);
giveUpdates([update(21, 'x')]);
await svc.pollOnce(signal);
// ... so it counts from one again when it comes back.
links.stopIn.mockRejectedValue(new Error('boom'));
leaseGranted(7n);
giveUpdates([update(10, '/stop')]);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
// Old by the injected clock: dropped by the next clean batch.
clock = 5_000;
links.stopIn.mockResolvedValue(undefined);
leaseGranted(7n);
giveUpdates([update(30, 'x')]);
await svc.pollOnce(signal);
links.stopIn.mockRejectedValue(new Error('boom'));
leaseGranted(7n);
giveUpdates([update(10, '/stop')]);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
});
it('the injected limits and clock decide when an update is skipped', async () => {
let clock = 0;
const svc = make({ attempts: 99, windowMs: 1_000, now: () => clock });
giveUpdates([update(10, '/stop')]);
links.stopIn.mockRejectedValue(new Error('boom'));
await expect(svc.pollOnce(signal)).resolves.toBe('error');
clock = 1_000;
await expect(svc.pollOnce(signal)).resolves.toBe('polled');
});
it('connection errors do not count toward the limit', async () => {
const svc = make({ attempts: 2 });
giveUpdates([update(10, '/stop')]);
const down = [
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1001',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1002',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1008',
clientVersion: 'test',
}),
new Prisma.PrismaClientKnownRequestError('x', {
code: 'P1017',
clientVersion: 'test',
}),
Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }),
new TelegramApiError('sendMessage', null, 'timeout', null),
];
for (const error of down) {
links.stopIn.mockRejectedValueOnce(error);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
}
expect(links.stopIn).toHaveBeenCalledTimes(down.length);
// Other database errors still count: the second one skips.
const other = new Prisma.PrismaClientKnownRequestError('x', {
code: 'P2002',
clientVersion: 'test',
});
links.stopIn.mockRejectedValue(other);
await expect(svc.pollOnce(signal)).resolves.toBe('error');
await expect(svc.pollOnce(signal)).resolves.toBe('polled');
});
});
it('moves the offset past a batch of only unparseable updates', async () => {
leaseGranted(7n);
giveUpdates([], 12);
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
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';
@@ -18,6 +18,17 @@ 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;
@@ -59,6 +70,34 @@ function describeError(error: unknown): string {
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();
@@ -77,6 +116,8 @@ function parseCommand(text: string | null): Command {
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();
@@ -88,11 +129,11 @@ class ReplyLimiter {
/** Counts a reply that was queued and committed. */
record(chatId: bigint): void {
const key = String(chatId);
this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), performance.now()]);
this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), this.now()]);
}
private prune(): void {
const now = performance.now();
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) {
@@ -119,7 +160,12 @@ export class TelegramUpdatesService {
private readonly logger = new Logger(TelegramUpdatesService.name);
/** Names this receiver in the lease. */
private readonly instanceId = randomUUID();
private readonly replies = new ReplyLimiter();
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;
@@ -128,38 +174,32 @@ export class TelegramUpdatesService {
bigint,
{ attempts: number; firstAt: number }
>();
/** Test seam: after how many attempts / how long a failing update is skipped. */
retryLimits = {
attempts: MAX_UPDATE_ATTEMPTS,
windowMs: MAX_UPDATE_RETRY_MS,
};
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. A
* cycle that ends because of shutdown hands the lease back.
* them one by one. 'polled' means the next cycle may start at once.
*/
async pollOnce(signal: AbortSignal): Promise<PollOutcome> {
try {
return await this.cycle(signal);
} finally {
if (signal.aborted && this.holdsLease) {
await this.releaseLease();
}
}
}
private async cycle(signal: AbortSignal): Promise<PollOutcome> {
if (!this.client.isConfigured()) {
return 'disabled';
}
if (signal.aborted || performance.now() < this.rateLimitedUntil) {
if (signal.aborted || this.now() < this.rateLimitedUntil) {
return 'error';
}
const offset = await this.acquireLease();
@@ -172,6 +212,12 @@ export class TelegramUpdatesService {
} 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);
}
@@ -217,12 +263,23 @@ export class TelegramUpdatesService {
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,
@@ -256,8 +313,14 @@ export class TelegramUpdatesService {
return rows[0]?.updateOffset ?? null;
}
/** Shutdown: the next receiver need not wait for the lease to run out. */
private async releaseLease(): Promise<void> {
/**
* 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`
@@ -307,7 +370,7 @@ export class TelegramUpdatesService {
const seconds =
error instanceof TelegramApiError ? error.retryAfterSec : null;
this.rateLimitedUntil =
performance.now() +
this.now() +
Math.min(
(seconds ?? NO_LEASE_WAIT_MS / 1_000) * 1_000,
MAX_RATE_LIMIT_PAUSE_MS,
@@ -342,7 +405,12 @@ export class TelegramUpdatesService {
update: TelegramUpdate,
error: unknown,
): Promise<boolean> {
const now = performance.now();
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,
@@ -350,8 +418,8 @@ export class TelegramUpdatesService {
};
this.failures.set(update.updateId, state);
const exhausted =
state.attempts >= this.retryLimits.attempts ||
now - state.firstAt >= this.retryLimits.windowMs;
state.attempts >= this.limits.attempts ||
now - state.firstAt >= this.limits.windowMs;
if (!exhausted) {
await this.onUpdateError(error);
}
@@ -74,6 +74,7 @@ class FakeTelegramSender {
}
class FakeTelegramUpdates {
releaseLease = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
pollOnce = jest
.fn<Promise<string>, [AbortSignal]>()
.mockResolvedValue('no-lease');
@@ -350,6 +351,15 @@ describe('WorkerRuntime', () => {
expect(logError).toHaveBeenCalled();
});
it('hands the lease back at shutdown, after the loops stopped', async () => {
await bootstrap();
telegramUpdates.releaseLease.mockClear();
await runtime.onModuleDestroy();
expect(telegramUpdates.releaseLease).toHaveBeenCalledTimes(1);
});
it('aborts the running poll at shutdown', async () => {
await bootstrap();
const [signal] = telegramUpdates.pollOnce.mock.calls[0];
@@ -188,6 +188,9 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
this.listener = undefined;
await listener?.stop();
await Promise.all([...this.loops.values()].map((loop) => loop.stop()));
// Also covers a stop during the 30 s wait: the next receiver need not
// wait for the lease to run out.
await this.telegramUpdates.releaseLease();
}
/**
@@ -367,7 +367,7 @@ describe('Notifications (e2e)', () => {
linkedAt: null,
enabled: false,
});
expect(typeof telegram.configured).toBe('boolean');
expect(typeof telegram.botNameSet).toBe('boolean');
});
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
+46 -6
View File
@@ -823,7 +823,7 @@ describe('Telegram delivery (e2e)', () => {
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect((view.body as { telegram: object }).telegram).toMatchObject({
configured: true,
botNameSet: true,
botUsername: BOT,
linked: true,
firstName: 'unlink',
@@ -940,23 +940,28 @@ describe('Telegram delivery (e2e)', () => {
const stop = jest
.spyOn(links, 'stopIn')
.mockRejectedValue(new Error('boom'));
updates.retryLimits = { attempts: 2, windowMs: 600_000 };
// A receiver with an injected limit of two attempts.
const strict = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
{ attempts: 2 },
);
const first = nextUpdateId;
say(user.chatId, '/stop');
say(other, 'hallo');
try {
await expect(poll()).resolves.toBe('error');
await expect(strict.pollOnce(NEVER_ABORTED)).resolves.toBe('error');
const failing = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(failing.updateOffset).toBe(BigInt(first));
expect(failing.lastError).toBe('update not handled: Error');
await expect(poll()).resolves.toBe('polled');
await expect(strict.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
} finally {
stop.mockRestore();
updates.retryLimits = { attempts: 5, windowMs: 600_000 };
}
const state = await prisma.telegramPollState.findUniqueOrThrow({
@@ -971,13 +976,48 @@ describe('Telegram delivery (e2e)', () => {
expect(await repliesTo(other)).toEqual(['HELP']);
// A clean batch afterwards clears the notice.
await poll();
await strict.pollOnce(NEVER_ABORTED);
const cleared = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(cleared.lastError).toBeNull();
});
it('releasing the lease at shutdown lets the next receiver in at once', async () => {
const links = app.get(TelegramLinkService);
const client = telegram as unknown as TelegramClient;
const first = new TelegramUpdatesService(prisma, client, links);
const second = new TelegramUpdatesService(prisma, client, links);
await expect(first.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(second.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
await first.releaseLease();
await expect(second.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
});
it('an old backlog never reaches a chat linked later', async () => {
const user = await createUnlinkedUser('backlog');
const waiting = await queue(user.id, { message: 'alt' });
await prisma.telegramLink.create({
data: { userId: user.id, chatId: user.chatId, telegramFirstName: 'x' },
});
await prisma.notificationPreference.update({
where: { userId: user.id },
data: { telegramEnabled: true },
});
const token = await newLinkToken(user.id);
const newChat = chatBase + 9_400n;
say(newChat, `/start ${token}`);
await poll();
expect((await deliveryOf(waiting.deliveryId)).status).toBe('SKIPPED');
await drainFor(user.id);
expect(telegram.sentTo(newChat)).toHaveLength(0);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
});
// Last on purpose: it uses up the 10-per-minute budget of the route.
it('link-token requires step-up and is throttled', async () => {
await createUnlinkedUser('stepup');
+1 -1
View File
@@ -82,7 +82,7 @@ export class FakeTelegramClient {
: highest,
null,
);
return { updates: due, highestUpdateId };
return { updates: due, highestUpdateId, received: due.length };
}
sentTo(chatId: bigint): SentMessage[] {