mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry- policy types). Until this release the dispatcher fired channels best- effort and kept retrying every 60s on a 410 Gone — that meant a stale Web-Push subscription never recovered, a Telegram bot whose chat was deleted got the same treatment, and the user had no visibility into WHY their phone had stopped buzzing. Reliability rules now: - HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" / "blocked by the user") → channel auto-disabled, disabled_reason captured, audit log notification.channel.auto_disabled written. Sender NEVER retried until the user clicks "Re-enable" in Settings. - SOFT reject (5xx, 429, network timeout) → exponential backoff: [30s, 5min, 30min, 2h]. Counter consecutive_failures increments per failure; after the 5th in-a-row the channel is auto-disabled with reason give_up_after_5_failures. - COOLDOWN window (next_retry_at > now) → dispatcher skips the channel without calling the sender — defeats the API-quota-burn loop a flapping upstream would otherwise create. Implementation: - Migration 0028 adds 6 columns to notification_channels (all nullable / zero-default → no-op for existing rows). - src/lib/notifications/retry-policy.ts: classifiers + frozen BACKOFF_SCHEDULE_MS + nextRetryAt() helpers. - src/lib/notifications/channel-state.ts: only writer of the new columns (recordChannelSuccess, recordChannelHardReject, recordChannelTransientFailure, reEnableChannel, isChannelInCooldown). Each transient/hard path writes audit. - Senders return a structured SendOutcome instead of bare booleans. - Dispatcher: cooldown-skip → success → hard-reject → transient/ give-up. Sender-throws absorbed as transient failures. Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts, dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint clean (no new warnings). Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts had if (!ok) against a SendMessageResult object — always truthy, so the route never reported test-send failures. Now reads result.ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
4.8 KiB
TypeScript
185 lines
4.8 KiB
TypeScript
/**
|
|
* Minimal Telegram Bot API client for sending messages.
|
|
* Uses the HTTP API directly — no library needed.
|
|
*/
|
|
import { getEvent } from "@/lib/logging/context";
|
|
|
|
interface TelegramResponse {
|
|
ok: boolean;
|
|
result?: unknown;
|
|
description?: string;
|
|
}
|
|
|
|
interface TelegramInlineKeyboardButton {
|
|
text: string;
|
|
callback_data: string;
|
|
}
|
|
|
|
interface TelegramReplyMarkup {
|
|
inline_keyboard: TelegramInlineKeyboardButton[][];
|
|
}
|
|
|
|
interface SendMessageOptions {
|
|
parseMode?: "HTML" | "MarkdownV2";
|
|
replyMarkup?: TelegramReplyMarkup;
|
|
}
|
|
|
|
async function telegramApiRequest(
|
|
botToken: string,
|
|
method: string,
|
|
payload: Record<string, unknown>,
|
|
): Promise<TelegramResponse> {
|
|
const start = performance.now();
|
|
try {
|
|
const res = await fetch(
|
|
`https://api.telegram.org/bot${botToken}/${method}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(10_000),
|
|
},
|
|
);
|
|
const data = (await res.json()) as TelegramResponse;
|
|
getEvent()?.addExternalCall({
|
|
service: "telegram",
|
|
method,
|
|
duration_ms: Math.round(performance.now() - start),
|
|
status: res.status,
|
|
error: data.ok ? undefined : data.description,
|
|
});
|
|
return data;
|
|
} catch (err) {
|
|
getEvent()?.addExternalCall({
|
|
service: "telegram",
|
|
method,
|
|
duration_ms: Math.round(performance.now() - start),
|
|
error: err instanceof Error ? err.message : "request_failed",
|
|
});
|
|
return { ok: false, description: "request_failed" };
|
|
}
|
|
}
|
|
|
|
export interface SendMessageResult {
|
|
ok: boolean;
|
|
messageId?: number;
|
|
/**
|
|
* Bot-API `description` field on failure. Surfaced so the dispatcher
|
|
* (v1.4.15 Phase B3) can classify "chat not found" / "blocked by the
|
|
* user" as hard rejects vs a generic 5xx as a soft reject.
|
|
*/
|
|
errorDescription?: string;
|
|
}
|
|
|
|
/**
|
|
* Send a text message via the Telegram Bot API.
|
|
* Returns { ok, messageId } on success, { ok: false, errorDescription } on
|
|
* failure (never throws).
|
|
*/
|
|
export async function sendTelegramMessage(
|
|
botToken: string,
|
|
chatId: string,
|
|
text: string,
|
|
options: SendMessageOptions = {},
|
|
): Promise<SendMessageResult> {
|
|
const json = await telegramApiRequest(botToken, "sendMessage", {
|
|
chat_id: chatId,
|
|
text,
|
|
parse_mode: options.parseMode ?? "HTML",
|
|
...(options.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(
|
|
`[telegram] sendMessage failed: ${json.description}`,
|
|
);
|
|
return { ok: false, errorDescription: json.description };
|
|
}
|
|
const messageId = (json.result as { message_id?: number })?.message_id;
|
|
return { ok: true, messageId };
|
|
}
|
|
|
|
export async function answerTelegramCallbackQuery(
|
|
botToken: string,
|
|
callbackQueryId: string,
|
|
text?: string,
|
|
): Promise<boolean> {
|
|
const json = await telegramApiRequest(botToken, "answerCallbackQuery", {
|
|
callback_query_id: callbackQueryId,
|
|
...(text ? { text, show_alert: false } : {}),
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(
|
|
`[telegram] answerCallbackQuery failed: ${json.description}`,
|
|
);
|
|
}
|
|
return json.ok;
|
|
}
|
|
|
|
export async function editMessageReplyMarkup(
|
|
botToken: string,
|
|
chatId: string,
|
|
messageId: number,
|
|
replyMarkup?: TelegramReplyMarkup,
|
|
): Promise<boolean> {
|
|
const json = await telegramApiRequest(botToken, "editMessageReplyMarkup", {
|
|
chat_id: chatId,
|
|
message_id: messageId,
|
|
reply_markup: replyMarkup ?? { inline_keyboard: [] },
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(
|
|
`[telegram] editMessageReplyMarkup failed: ${json.description}`,
|
|
);
|
|
}
|
|
return json.ok;
|
|
}
|
|
|
|
export async function setTelegramWebhook(
|
|
botToken: string,
|
|
webhookUrl: string,
|
|
secretToken?: string,
|
|
): Promise<boolean> {
|
|
const json = await telegramApiRequest(botToken, "setWebhook", {
|
|
url: webhookUrl,
|
|
...(secretToken ? { secret_token: secretToken } : {}),
|
|
drop_pending_updates: false,
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(`[telegram] setWebhook failed: ${json.description}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function deleteMessage(
|
|
botToken: string,
|
|
chatId: string,
|
|
messageId: number,
|
|
): Promise<boolean> {
|
|
const json = await telegramApiRequest(botToken, "deleteMessage", {
|
|
chat_id: chatId,
|
|
message_id: messageId,
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(
|
|
`[telegram] deleteMessage failed: ${json.description}`,
|
|
);
|
|
}
|
|
return json.ok;
|
|
}
|
|
|
|
export async function deleteTelegramWebhook(
|
|
botToken: string,
|
|
): Promise<boolean> {
|
|
const json = await telegramApiRequest(botToken, "deleteWebhook", {
|
|
drop_pending_updates: false,
|
|
});
|
|
if (!json.ok) {
|
|
getEvent()?.addWarning(
|
|
`[telegram] deleteWebhook failed: ${json.description}`,
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|