Files
HealthLog/src/lib/telegram.ts
T
Marc-André Bombeck b24a893b66 feat(telegram): log mood and act on measurement reminders in chat
Extend the self-cleaning Telegram bot beyond medication. A mood reminder
now carries a 1-5 inline keyboard plus a "Note..." force-reply and a
"Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side,
user resolved from the linked chat, idempotent on a per-tap externalId)
and confirms with a calm callback toast, no chat echo. An optional
free-text note attaches to that entry via the prompt-context correlation.
A measurement reminder carries "Done" (satisfies the cadence) and
"Later" (pushes nextDueAt forward) — completion and snooze only, no value
capture.

Prompts and replies self-clean in ~30 minutes: the window drops from 1h
and moves to a shared helper, the sender schedules an unanswered prompt's
deletion at send time, and the existing 15-min sweep also prunes expired
prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual
+ rate-limit-before-secret posture; every write callback authenticates via
the stored chat binding. The Telegram chat id is added to the egress
redaction denylist.
2026-06-21 11:13:33 +02:00

178 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Minimal Telegram Bot API client for sending messages.
* Uses the HTTP API directly — no library needed.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
interface TelegramResponse {
ok: boolean;
result?: unknown;
description?: string;
}
interface TelegramInlineKeyboardButton {
text: string;
callback_data: string;
}
interface TelegramReplyMarkup {
inline_keyboard: TelegramInlineKeyboardButton[][];
}
/**
* v1.19.0 — force-reply markup. Opens the user's reply box pre-targeted at
* the bot's message; the reply then arrives as a normal `message` whose
* `reply_to_message` points back at this prompt (the linkage the mood-note
* flow uses). `input_field_placeholder` is bounded 1–64 chars by the API.
*/
interface TelegramForceReply {
force_reply: true;
input_field_placeholder?: string;
}
interface SendMessageOptions {
parseMode?: "HTML" | "MarkdownV2";
replyMarkup?: TelegramReplyMarkup | TelegramForceReply;
}
async function telegramApiRequest(
botToken: string,
method: string,
payload: Record<string, unknown>,
): Promise<TelegramResponse> {
const start = performance.now();
try {
const res = await safeFetch(
`https://api.telegram.org/bot${botToken}/${method}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
{ timeoutMs: 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 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;
}