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]>
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -82,7 +82,7 @@ export class FakeTelegramClient {
|
||||
: highest,
|
||||
null,
|
||||
);
|
||||
return { updates: due, highestUpdateId };
|
||||
return { updates: due, highestUpdateId, received: due.length };
|
||||
}
|
||||
|
||||
sentTo(chatId: bigint): SentMessage[] {
|
||||
|
||||
Reference in new issue
Block a user