feat(api): Telegram — темы, очередь повторов, «Система», /setup

telegram/client: Bot API на fetch с таймаутом 5 с; ошибки делятся на
повторяемые (сеть, таймаут, 429, 5xx) и конфигурационные (400/403…),
токен не попадает ни в тексты ошибок, ни в tg_last_error.
format: заявка и уточнение по шаблону плана, HTML с экранированием,
источник из utm / referrer, первый визит.
outbox: Notifier с отправкой после ответа и тиком раз в минуту, пауза
1, 2, 4… мин, уточнение только после своей заявки, после 10 попыток —
в «Систему». system: тема «Система» с троттлингом ×N за период.
bot: long polling, только TELEGRAM_ADMIN_ID, /setup создаёт темы и
присылает id в личку. config: всё окружение в одном месте.
This commit is contained in:
2026-10-17 16:07:26 +03:00
parent 5d970be228
commit 667c6a6e6d
15 changed files with 1316 additions and 22 deletions
+64
View File
@@ -0,0 +1,64 @@
// Тема «Kvarta Cafe: Система»: сбои и служебные события без персональных данных.
// Всё дублируется в stdout — это единственный канал, если Telegram недоступен или тема не задана.
import type { TelegramClient } from './client.js';
export const SYSTEM_THROTTLE_MS = 10 * 60 * 1000;
export type SystemReporter = {
/** Никогда не бросает: отчёт о сбое не должен порождать новый сбой. */
report(kind: string, text: string): Promise<void>;
};
export function createSystemReporter({
client,
chatId,
threadId,
now,
log = (line: string) => console.log(line),
}: {
client?: Pick<TelegramClient, 'sendMessage'>;
chatId?: number;
threadId?: number;
now: () => Date;
log?: (line: string) => void;
}): SystemReporter {
// Одинаковые события не чаще раза в 10 минут; пропущенные копим и показываем с ближайшим отправленным.
const throttle = new Map<string, { sentAt: number; skipped: number }>();
return {
async report(kind, text) {
log(`[system] ${kind}: ${text}`);
if (!client || chatId === undefined || threadId === undefined) return;
const t = now().getTime();
const state = throttle.get(kind);
if (state && t - state.sentAt < SYSTEM_THROTTLE_MS) {
state.skipped++;
return;
}
const count = (state?.skipped ?? 0) + 1;
throttle.set(kind, { sentAt: t, skipped: 0 });
try {
await client.sendMessage({
chat_id: chatId,
message_thread_id: threadId,
text: count > 1 ? `×${count} за период · ${text}` : text,
});
} catch (err) {
log(`[system] не отправлено в Telegram: ${err instanceof Error ? err.message : String(err)}`);
}
},
};
}
/** Короткий stack без путей до node_modules: в «Систему» и stdout. */
export function shortError(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const frames = (err.stack ?? '')
.split('\n')
.slice(1)
.filter((l) => !l.includes('node_modules') && !l.includes('node:'))
.slice(0, 3)
.map((l) => l.trim());
return [`${err.name}: ${err.message}`, ...frames].join('\n');
}