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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user