mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
178 lines
4.7 KiB
TypeScript
178 lines
4.7 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|