diff --git a/messages/de.json b/messages/de.json
index 7886020fd..ae53086c3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} bestätigt.",
"genericConfirmed": "Bestätigt.",
"cancelled": "Abgebrochen.",
+ "moodLogged": "Notiert: {score}/5",
+ "moodNotePrompt": "Kurze Notiz hinzufügen (optional).",
+ "moodNoteSaved": "Notiz gespeichert.",
+ "moodNoteExpired": "Diese Abfrage ist abgelaufen.",
+ "remindLater": "Erinnerung um {duration} verschoben.",
+ "measurementDone": "Als erledigt markiert.",
+ "measurementDismissed": "Verworfen.",
+ "measurementNotFound": "Erinnerung nicht gefunden.",
+ "buttonMoodNote": "Notiz…",
+ "buttonLater": "Später",
+ "buttonDone": "Erledigt",
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
"whichMedication": "Welches Medikament eingenommen?",
"cancelButton": "Abbrechen",
diff --git a/messages/en.json b/messages/en.json
index e813ab262..c61161b0f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} confirmed.",
"genericConfirmed": "Confirmed.",
"cancelled": "Cancelled.",
+ "moodLogged": "Logged: {score}/5",
+ "moodNotePrompt": "Add a short note (optional).",
+ "moodNoteSaved": "Note saved.",
+ "moodNoteExpired": "That prompt has expired.",
+ "remindLater": "Reminder postponed for {duration}.",
+ "measurementDone": "Marked as done.",
+ "measurementDismissed": "Dismissed.",
+ "measurementNotFound": "Reminder not found.",
+ "buttonMoodNote": "Note…",
+ "buttonLater": "Later",
+ "buttonDone": "Done",
"noActiveMedications": "No active medications found.",
"whichMedication": "Which medication did you take?",
"cancelButton": "Cancel",
diff --git a/messages/es.json b/messages/es.json
index 0af72fc59..b055c99f0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} confirmado.",
"genericConfirmed": "Confirmado.",
"cancelled": "Cancelado.",
+ "moodLogged": "Registrado: {score}/5",
+ "moodNotePrompt": "Añade una nota breve (opcional).",
+ "moodNoteSaved": "Nota guardada.",
+ "moodNoteExpired": "Esa solicitud ha caducado.",
+ "remindLater": "Recordatorio pospuesto {duration}.",
+ "measurementDone": "Marcado como hecho.",
+ "measurementDismissed": "Descartado.",
+ "measurementNotFound": "Recordatorio no encontrado.",
+ "buttonMoodNote": "Nota…",
+ "buttonLater": "Más tarde",
+ "buttonDone": "Hecho",
"noActiveMedications": "No se han encontrado medicamentos activos.",
"whichMedication": "¿Qué medicamento has tomado?",
"cancelButton": "Cancelar",
diff --git a/messages/fr.json b/messages/fr.json
index 6bdd9bb49..3b5857cdb 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} confirmé.",
"genericConfirmed": "Confirmé.",
"cancelled": "Annulé.",
+ "moodLogged": "Enregistré : {score}/5",
+ "moodNotePrompt": "Ajoutez une note brève (facultatif).",
+ "moodNoteSaved": "Note enregistrée.",
+ "moodNoteExpired": "Cette invite a expiré.",
+ "remindLater": "Rappel reporté de {duration}.",
+ "measurementDone": "Marqué comme fait.",
+ "measurementDismissed": "Ignoré.",
+ "measurementNotFound": "Rappel introuvable.",
+ "buttonMoodNote": "Note…",
+ "buttonLater": "Plus tard",
+ "buttonDone": "Fait",
"noActiveMedications": "Aucun médicament actif trouvé.",
"whichMedication": "Quel médicament as-tu pris ?",
"cancelButton": "Annuler",
diff --git a/messages/it.json b/messages/it.json
index dc8e1fe8b..a2d17149b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} confermato.",
"genericConfirmed": "Confermato.",
"cancelled": "Annullato.",
+ "moodLogged": "Registrato: {score}/5",
+ "moodNotePrompt": "Aggiungi una breve nota (facoltativo).",
+ "moodNoteSaved": "Nota salvata.",
+ "moodNoteExpired": "Questa richiesta è scaduta.",
+ "remindLater": "Promemoria posticipato di {duration}.",
+ "measurementDone": "Contrassegnato come fatto.",
+ "measurementDismissed": "Ignorato.",
+ "measurementNotFound": "Promemoria non trovato.",
+ "buttonMoodNote": "Nota…",
+ "buttonLater": "Più tardi",
+ "buttonDone": "Fatto",
"noActiveMedications": "Nessun farmaco attivo trovato.",
"whichMedication": "Quale farmaco hai assunto?",
"cancelButton": "Annulla",
diff --git a/messages/pl.json b/messages/pl.json
index a1043bf8c..a06409a91 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5916,6 +5916,17 @@
"confirmed": "{name} potwierdzone.",
"genericConfirmed": "Potwierdzone.",
"cancelled": "Anulowane.",
+ "moodLogged": "Zapisano: {score}/5",
+ "moodNotePrompt": "Dodaj krótką notatkę (opcjonalnie).",
+ "moodNoteSaved": "Notatka zapisana.",
+ "moodNoteExpired": "Ta prośba wygasła.",
+ "remindLater": "Przypomnienie przełożone o {duration}.",
+ "measurementDone": "Oznaczono jako wykonane.",
+ "measurementDismissed": "Odrzucono.",
+ "measurementNotFound": "Nie znaleziono przypomnienia.",
+ "buttonMoodNote": "Notatka…",
+ "buttonLater": "Później",
+ "buttonDone": "Gotowe",
"noActiveMedications": "Nie znaleziono aktywnych leków.",
"whichMedication": "Który lek został przyjęty?",
"cancelButton": "Anuluj",
diff --git a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql
new file mode 100644
index 000000000..aab9f45f5
--- /dev/null
+++ b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql
@@ -0,0 +1,20 @@
+-- v1.19.0 — force-reply correlation for the interactive mood reminder.
+-- Maps a bot prompt message to the entry a free-text reply attaches to.
+CREATE TABLE "telegram_prompt_contexts" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "chat_id" TEXT NOT NULL,
+ "prompt_msg_id" INTEGER NOT NULL,
+ "kind" TEXT NOT NULL,
+ "ref_id" TEXT NOT NULL,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "telegram_prompt_contexts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id");
+
+-- CreateIndex
+CREATE INDEX "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f07426b66..e70160cad 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2262,6 +2262,33 @@ model TelegramScheduledDeletion {
@@map("telegram_scheduled_deletions")
}
+// v1.19.0 — force-reply correlation for the interactive mood reminder.
+//
+// When a user taps "Notiz…" on a mood prompt the bot sends a force_reply
+// message; the user's free-text reply then arrives as a normal `message`
+// whose `reply_to_message.message_id` points back at that prompt. This
+// row maps `(chatId, promptMessageId)` to the mood entry the note should
+// attach to, so the inbound note handler can resolve "what was this a
+// reply to" without trusting any client-supplied id. Rows are short-lived
+// (`expiresAt`) and best-effort deleted once consumed; the deletion sweep
+// is keyed on `expiresAt` so an unanswered note prompt self-cleans.
+model TelegramPromptContext {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ chatId String @map("chat_id")
+ // The bot's prompt message the user is expected to reply to.
+ promptMsgId Int @map("prompt_msg_id")
+ // What the reply attaches to. "mood_note" carries `refId = moodEntryId`.
+ kind String
+ refId String @map("ref_id")
+ expiresAt DateTime @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@unique([chatId, promptMsgId])
+ @@index([expiresAt])
+ @@map("telegram_prompt_contexts")
+}
+
// ─── API Tokens (for external ingest) ──────────────────────
model ApiToken {
diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts
index 301322e33..634857667 100644
--- a/src/app/api/telegram/webhook/__tests__/route.test.ts
+++ b/src/app/api/telegram/webhook/__tests__/route.test.ts
@@ -28,10 +28,36 @@ vi.mock("@/lib/db", () => ({
telegramScheduledDeletion: {
createMany: vi.fn(),
},
+ telegramPromptContext: {
+ create: vi.fn(),
+ findUnique: vi.fn(),
+ delete: vi.fn(),
+ },
+ moodReminderDispatch: {
+ deleteMany: vi.fn(),
+ },
+ measurementReminder: {
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
$transaction: vi.fn(),
},
}));
+vi.mock("@/lib/mood/create-from-telegram", () => ({
+ logTelegramMood: vi.fn(),
+ attachTelegramMoodNote: vi.fn(),
+}));
+
+vi.mock("@/lib/telegram-cleanup", () => ({
+ scheduleTelegramAutoDelete: vi.fn().mockResolvedValue(undefined),
+ TELEGRAM_AUTO_DELETE_DELAY_MS: 30 * 60 * 1000,
+}));
+
+vi.mock("@/lib/measurement-reminders/satisfy", () => ({
+ satisfyReminder: vi.fn().mockResolvedValue({ satisfied: true }),
+}));
+
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => `decrypted:${value}`),
}));
@@ -77,6 +103,12 @@ import {
sendTelegramMessage,
} from "@/lib/telegram";
import { checkRateLimit } from "@/lib/rate-limit";
+import {
+ logTelegramMood,
+ attachTelegramMoodNote,
+} from "@/lib/mood/create-from-telegram";
+import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
+import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
@@ -155,6 +187,23 @@ beforeEach(() => {
messageId: 999,
ok: true,
} as never);
+ // v1.19.0 interactive mood + measurement defaults.
+ vi.mocked(logTelegramMood).mockResolvedValue({
+ created: true,
+ moodEntryId: "mood-1",
+ score: 4,
+ } as never);
+ vi.mocked(attachTelegramMoodNote).mockResolvedValue(true as never);
+ vi.mocked(scheduleTelegramAutoDelete).mockResolvedValue(undefined as never);
+ vi.mocked(satisfyReminder).mockResolvedValue({ satisfied: true } as never);
+ vi.mocked(prisma.telegramPromptContext.create).mockResolvedValue({} as never);
+ vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValue(null);
+ vi.mocked(prisma.telegramPromptContext.delete).mockResolvedValue({} as never);
+ vi.mocked(prisma.moodReminderDispatch.deleteMany).mockResolvedValue({
+ count: 0,
+ } as never);
+ vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValue(null);
+ vi.mocked(prisma.measurementReminder.update).mockResolvedValue({} as never);
});
afterEach(() => {
@@ -834,3 +883,177 @@ describe("Telegram webhook — GET verification", () => {
expect(res.status).toBe(401);
});
});
+
+describe("Telegram webhook — interactive mood (v1.19.0)", () => {
+ it("logs a MoodEntry on a 1–5 tap, scoped to the linked chat's user", async () => {
+ const res = await POST(tgRequest(callbackUpdate("mood:4")));
+ expect(res.status).toBe(200);
+ expect(logTelegramMood).toHaveBeenCalledTimes(1);
+ const arg = vi.mocked(logTelegramMood).mock.calls[0][0];
+ // userId resolved from the linked chat (TG_USER), never the payload.
+ expect(arg.userId).toBe("user-1");
+ expect(arg.score).toBe(4);
+ // Stable per-tap idempotency id keyed on chat + message + score.
+ expect(arg.externalId).toContain("telegram:mood:7777:555:4");
+ // Calm callback toast, prompt deleted (no chat echo of the score).
+ expect(answerTelegramCallbackQuery).toHaveBeenCalled();
+ expect(deleteMessage).toHaveBeenCalledWith(
+ "decrypted:ENC(token-blob)",
+ "7777",
+ 555,
+ );
+ });
+
+ it("rejects an out-of-range mood score without writing", async () => {
+ const res = await POST(tgRequest(callbackUpdate("mood:9")));
+ expect(res.status).toBe(200);
+ expect(logTelegramMood).not.toHaveBeenCalled();
+ });
+
+ it("rejects a mood tap from an unlinked / foreign chat", async () => {
+ vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null);
+ const res = await POST(
+ tgRequest(
+ callbackUpdate("mood:5", {
+ from: { id: 4242 },
+ message: { message_id: 1, chat: { id: 4242 } },
+ }),
+ ),
+ );
+ expect(res.status).toBe(200);
+ expect(logTelegramMood).not.toHaveBeenCalled();
+ expect(answerTelegramCallbackQuery).not.toHaveBeenCalled();
+ });
+
+ it("offers a force_reply note prompt and records its prompt context", async () => {
+ vi.mocked(sendTelegramMessage).mockResolvedValueOnce({
+ messageId: 4321,
+ ok: true,
+ } as never);
+ await POST(tgRequest(callbackUpdate("mood:3")));
+ const sendArgs = vi.mocked(sendTelegramMessage).mock.calls[0];
+ // force_reply markup opens the user's reply box pre-targeted at the bot.
+ expect(sendArgs[3]).toMatchObject({
+ replyMarkup: { force_reply: true },
+ });
+ expect(prisma.telegramPromptContext.create).toHaveBeenCalledTimes(1);
+ const ctx = vi.mocked(prisma.telegramPromptContext.create).mock.calls[0][0];
+ expect(ctx.data).toMatchObject({
+ userId: "user-1",
+ chatId: "7777",
+ promptMsgId: 4321,
+ kind: "mood_note",
+ refId: "mood-1",
+ });
+ // The note prompt is scheduled to self-clean (~30 min).
+ expect(scheduleTelegramAutoDelete).toHaveBeenCalledWith(
+ "user-1",
+ "7777",
+ [4321],
+ );
+ });
+
+ it("attaches a free-text reply to the just-logged mood entry", async () => {
+ vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({
+ id: "ctx-1",
+ userId: "user-1",
+ kind: "mood_note",
+ refId: "mood-1",
+ expiresAt: new Date(Date.now() + 60_000),
+ } as never);
+ const res = await POST(
+ tgRequest({
+ update_id: 900,
+ message: {
+ message_id: 60,
+ text: "Slept badly",
+ chat: { id: 7777 },
+ reply_to_message: { message_id: 4321 },
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(attachTelegramMoodNote).toHaveBeenCalledWith({
+ userId: "user-1",
+ moodEntryId: "mood-1",
+ note: "Slept badly",
+ });
+ // The prompt context row is consumed so a reply can't be replayed.
+ expect(prisma.telegramPromptContext.delete).toHaveBeenCalledWith({
+ where: { id: "ctx-1" },
+ });
+ // Note, prompt, and confirmation are scheduled to self-clean.
+ expect(scheduleTelegramAutoDelete).toHaveBeenCalled();
+ });
+
+ it("does not attach a note when the reply context belongs to another user", async () => {
+ vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({
+ id: "ctx-2",
+ userId: "someone-else",
+ kind: "mood_note",
+ refId: "mood-99",
+ expiresAt: new Date(Date.now() + 60_000),
+ } as never);
+ const res = await POST(
+ tgRequest({
+ update_id: 901,
+ message: {
+ message_id: 61,
+ text: "leak attempt",
+ chat: { id: 7777 },
+ reply_to_message: { message_id: 4321 },
+ },
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(attachTelegramMoodNote).not.toHaveBeenCalled();
+ });
+
+ it("mood_later clears today's dispatch ledger and deletes the prompt", async () => {
+ const res = await POST(tgRequest(callbackUpdate("mood_later:120")));
+ expect(res.status).toBe(200);
+ expect(prisma.moodReminderDispatch.deleteMany).toHaveBeenCalledTimes(1);
+ expect(deleteMessage).toHaveBeenCalled();
+ expect(logTelegramMood).not.toHaveBeenCalled();
+ });
+});
+
+describe("Telegram webhook — interactive measurement reminder (v1.19.0)", () => {
+ it("measure_done satisfies the reminder via the shared primitive", async () => {
+ vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({
+ id: "rem-1",
+ intervalDays: 7,
+ rrule: null,
+ anchorDate: null,
+ notifyHour: 9,
+ lastSatisfiedAt: null,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ } as never);
+ const res = await POST(tgRequest(callbackUpdate("measure_done:rem-1")));
+ expect(res.status).toBe(200);
+ expect(satisfyReminder).toHaveBeenCalledTimes(1);
+ expect(deleteMessage).toHaveBeenCalled();
+ });
+
+ it("measure_done on a foreign reminder is a no-op", async () => {
+ vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce(null);
+ const res = await POST(tgRequest(callbackUpdate("measure_done:rem-x")));
+ expect(res.status).toBe(200);
+ expect(satisfyReminder).not.toHaveBeenCalled();
+ });
+
+ it("measure_later pushes nextDueAt forward (one-shot snooze)", async () => {
+ vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({
+ id: "rem-2",
+ } as never);
+ const res = await POST(
+ tgRequest(callbackUpdate("measure_later:rem-2:180")),
+ );
+ expect(res.status).toBe(200);
+ expect(prisma.measurementReminder.update).toHaveBeenCalledTimes(1);
+ const arg = vi.mocked(prisma.measurementReminder.update).mock.calls[0][0];
+ expect(arg.where).toEqual({ id: "rem-2" });
+ expect(arg.data.nextDueAt).toBeInstanceOf(Date);
+ expect(deleteMessage).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts
index 9d480846d..5c25ed80e 100644
--- a/src/app/api/telegram/webhook/route.ts
+++ b/src/app/api/telegram/webhook/route.ts
@@ -27,6 +27,12 @@ import {
import { localHmAsUtc } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+import {
+ logTelegramMood,
+ attachTelegramMoodNote,
+} from "@/lib/mood/create-from-telegram";
+import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
+import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
interface TelegramUpdate {
update_id: number;
@@ -34,6 +40,9 @@ interface TelegramUpdate {
message_id?: number;
text?: string;
chat?: { id: number | string };
+ reply_to_message?: {
+ message_id?: number;
+ };
};
callback_query?: {
id: string;
@@ -96,26 +105,15 @@ async function cleanupReminderTracking(medicationId: string): Promise {
}
}
-const AUTO_DELETE_DELAY_MS = 60 * 60 * 1000; // 1 hour
-
+// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to
+// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text)
+// and outbound (unanswered prompt) paths agree on the window.
async function scheduleAutoDelete(
userId: string,
chatId: string,
messageIds: number[],
): Promise {
- const deleteAfter = new Date(Date.now() + AUTO_DELETE_DELAY_MS);
- try {
- await prisma.telegramScheduledDeletion.createMany({
- data: messageIds.map((messageId) => ({
- userId,
- chatId,
- messageId,
- deleteAfter,
- })),
- });
- } catch {
- // Best-effort
- }
+ await scheduleTelegramAutoDelete(userId, chatId, messageIds);
}
async function findTelegramUser(chatId: string) {
@@ -677,6 +675,183 @@ async function handleCallback(update: TelegramUpdate) {
callback.id,
t("telegram.cancelled"),
);
+ } else if (data.startsWith("mood:")) {
+ // v1.19.0 — log a mood entry 1–5 from a MOOD_REMINDER prompt tap.
+ // The score is the ONLY accepted value; the userId is resolved from
+ // the linked chat above, never from the payload. Confirmation is a
+ // calm callback toast — no chat echo (restraint).
+ const score = parseInt(data.slice("mood:".length).trim(), 10);
+ if (!Number.isInteger(score) || score < 1 || score > 5) {
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.errorInvalidAction"),
+ );
+ return;
+ }
+
+ const msgId = messageId ?? "na";
+ // Stable per-tap dedup id (Telegram redelivers non-200 updates). The
+ // mood-create helper upserts on `(userId, source, externalId)`.
+ const externalId = `telegram:mood:${chatId}:${msgId}:${score}`.slice(
+ 0,
+ 120,
+ );
+
+ const moodResult = await logTelegramMood({
+ userId: user.id,
+ score,
+ tz: user.timezone,
+ externalId,
+ });
+
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.moodLogged", { score: String(score) }),
+ );
+ // Delete the prompt immediately on a tap; the unanswered-prompt
+ // scheduled deletion (written at send time) is then a harmless no-op.
+ if (messageId) {
+ await deleteMessage(botToken, chatId, messageId);
+ }
+
+ // Offer an optional note via force_reply. The reply (when it arrives)
+ // is correlated back to this mood entry through TelegramPromptContext.
+ const notePrompt = await sendTelegramMessage(
+ botToken,
+ chatId,
+ t("telegram.moodNotePrompt"),
+ {
+ replyMarkup: {
+ force_reply: true,
+ input_field_placeholder: t("telegram.buttonMoodNote"),
+ },
+ },
+ );
+ if (notePrompt.ok && notePrompt.messageId) {
+ try {
+ await prisma.telegramPromptContext.create({
+ data: {
+ userId: user.id,
+ chatId,
+ promptMsgId: notePrompt.messageId,
+ kind: "mood_note",
+ refId: moodResult.moodEntryId,
+ // Force-reply context lifespan: the same ~30-min window as the
+ // deletion sweep. A reply after this is treated as expired.
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000),
+ },
+ });
+ } catch {
+ // Best-effort — without the context row the note simply can't bind.
+ }
+ // The note prompt itself self-cleans on the ~30-min sweep whether or
+ // not the user replies.
+ await scheduleAutoDelete(user.id, chatId, [notePrompt.messageId]);
+ }
+ } else if (data.startsWith("mood_later")) {
+ // v1.19.0 — restrained "remind me later" for the mood nudge. Clears
+ // today's dispatch ledger so the next cron tick (inside the user's
+ // reminder-hour window) re-fires the prompt, mirroring how the
+ // medication snooze lets the tick re-remind. The prompt is deleted now.
+ const tz = user.timezone || DEFAULT_TIMEZONE;
+ const parts = wallClockInTz(new Date(), tz);
+ const localDate = `${parts.year.toString().padStart(4, "0")}-${parts.month
+ .toString()
+ .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`;
+ try {
+ await prisma.moodReminderDispatch.deleteMany({
+ where: { userId: user.id, date: localDate },
+ });
+ } catch {
+ // Best-effort — a failure just means the day's nudge isn't re-armed.
+ }
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.remindLater", { duration: t("telegram.snoozeOneHour") }),
+ );
+ if (messageId) {
+ await deleteMessage(botToken, chatId, messageId);
+ }
+ } else if (data.startsWith("measure_done:")) {
+ // v1.19.0 — mark a Vorsorge/measurement reminder done. No value
+ // capture (restraint) — satisfy the cadence via the shared primitive.
+ const reminderId = data.slice("measure_done:".length).trim();
+ const reminder = await prisma.measurementReminder.findFirst({
+ where: { id: reminderId, userId: user.id, deletedAt: null },
+ select: {
+ id: true,
+ intervalDays: true,
+ rrule: true,
+ anchorDate: true,
+ notifyHour: true,
+ lastSatisfiedAt: true,
+ createdAt: true,
+ },
+ });
+ if (!reminder) {
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.measurementNotFound"),
+ );
+ return;
+ }
+ const tz = user.timezone || DEFAULT_TIMEZONE;
+ await satisfyReminder(prisma, reminder, tz, new Date());
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.measurementDone"),
+ );
+ if (messageId) {
+ await deleteMessage(botToken, chatId, messageId);
+ }
+ } else if (data.startsWith("measure_later:")) {
+ // v1.19.0 — postpone a measurement reminder by N minutes (one-shot
+ // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in
+ // the user's notify-hour window. Mirrors the medication snooze.
+ const parts = data.split(":");
+ const reminderId = parts[1];
+ const minutes = parseInt(parts[2] ?? "180", 10);
+ if (!reminderId || !Number.isFinite(minutes)) {
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.errorInvalidAction"),
+ );
+ return;
+ }
+ const reminder = await prisma.measurementReminder.findFirst({
+ where: { id: reminderId, userId: user.id, deletedAt: null },
+ select: { id: true },
+ });
+ if (!reminder) {
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.measurementNotFound"),
+ );
+ return;
+ }
+ await prisma.measurementReminder.update({
+ where: { id: reminder.id },
+ data: { nextDueAt: new Date(Date.now() + minutes * 60000) },
+ });
+ const duration =
+ minutes <= 60
+ ? t("telegram.snoozeOneHour")
+ : t("telegram.snoozeThreeHours");
+ await answerTelegramCallbackQuery(
+ botToken,
+ callback.id,
+ t("telegram.remindLater", { duration }),
+ );
+ if (messageId) {
+ await deleteMessage(botToken, chatId, messageId);
+ }
} else {
await answerTelegramCallbackQuery(
botToken,
@@ -698,6 +873,69 @@ async function handleTextMessage(update: TelegramUpdate) {
const locale = resolveBotLocale(user.locale);
const { t } = getServerTranslator(locale);
+ // v1.19.0 — force-reply note capture. When this message replies to a
+ // tracked mood-note prompt, attach the text to the just-logged mood entry
+ // and clean up both messages. The context row is keyed on
+ // `(chatId, promptMsgId)` and scoped to THIS user, so a reply can only
+ // ever attach to that user's own entry.
+ const replyToId = message?.reply_to_message?.message_id;
+ if (replyToId !== undefined) {
+ const context = await prisma.telegramPromptContext.findUnique({
+ where: {
+ chatId_promptMsgId: { chatId, promptMsgId: replyToId },
+ },
+ select: {
+ id: true,
+ userId: true,
+ kind: true,
+ refId: true,
+ expiresAt: true,
+ },
+ });
+ if (context && context.userId === user.id && context.kind === "mood_note") {
+ // Consume the context row regardless of outcome so a reply can't be
+ // replayed onto the entry twice.
+ await prisma.telegramPromptContext
+ .delete({ where: { id: context.id } })
+ .catch(() => {});
+ const userMsgId = message?.message_id;
+ if (context.expiresAt.getTime() < Date.now()) {
+ const resp = await sendTelegramMessage(
+ botToken,
+ chatId,
+ t("telegram.moodNoteExpired"),
+ );
+ const toDelete = [userMsgId, replyToId, resp.messageId].filter(
+ (id): id is number => id != null,
+ );
+ if (toDelete.length > 0) {
+ await scheduleAutoDelete(user.id, chatId, toDelete);
+ }
+ return;
+ }
+ const attached = await attachTelegramMoodNote({
+ userId: user.id,
+ moodEntryId: context.refId,
+ note: text,
+ });
+ const resp = await sendTelegramMessage(
+ botToken,
+ chatId,
+ attached
+ ? t("telegram.moodNoteSaved")
+ : t("telegram.errorInvalidAction"),
+ );
+ // Self-clean the note, the prompt, and the confirmation in ~30 min.
+ const toDelete = [userMsgId, replyToId, resp.messageId].filter(
+ (id): id is number => id != null,
+ );
+ if (toDelete.length > 0) {
+ await scheduleAutoDelete(user.id, chatId, toDelete);
+ }
+ return;
+ }
+ }
+
// "Help" / start: accept English + German keyword aliases independent of locale
if (
/^\/help\b/i.test(text) ||
diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts
index 8a017b0b0..647846e48 100644
--- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts
+++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts
@@ -14,6 +14,9 @@ const prismaMock = {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
+ telegramPromptContext: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
medication: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findMany: vi.fn().mockResolvedValue([]),
@@ -72,10 +75,25 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
beforeEach(() => {
vi.clearAllMocks();
prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]);
+ prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.medication.updateMany.mockResolvedValue({ count: 0 });
prismaMock.medication.findMany.mockResolvedValue([]);
});
+describe("handleReminderCheck — telegram prompt-context cleanup (v1.19.0)", () => {
+ it("prunes expired force-reply prompt contexts in the same sweep tick", async () => {
+ await handleReminderCheck([]);
+ expect(prismaMock.telegramPromptContext.deleteMany).toHaveBeenCalledTimes(
+ 1,
+ );
+ const arg = prismaMock.telegramPromptContext.deleteMany.mock
+ .calls[0][0] as {
+ where: { expiresAt: { lte: Date } };
+ };
+ expect(arg.where.expiresAt.lte).toBeInstanceOf(Date);
+ });
+});
+
describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => {
it("queries only active NON-as-needed medications", async () => {
await handleReminderCheck([]);
diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts
index 70b683828..5680f6e03 100644
--- a/src/lib/jobs/reminder/medication-reminder-check.ts
+++ b/src/lib/jobs/reminder/medication-reminder-check.ts
@@ -106,6 +106,22 @@ export async function cleanupScheduledTelegramDeletions(): Promise {
const { getEvent } = await import("@/lib/logging/context");
getEvent()?.addMeta("telegram_scheduled_cleanup", totalDeleted);
}
+
+ // v1.19.0 — prune expired force-reply correlation rows in the same
+ // tick (no new sweep). An unanswered mood-note prompt leaves a
+ // TelegramPromptContext row; once past `expiresAt` it can never bind a
+ // reply, so drop it. The prompt MESSAGE itself self-cleans through the
+ // scheduled-deletion rows above.
+ const prunedContexts = await prisma.telegramPromptContext.deleteMany({
+ where: { expiresAt: { lte: new Date() } },
+ });
+ if (prunedContexts.count > 0) {
+ const { getEvent } = await import("@/lib/logging/context");
+ getEvent()?.addMeta(
+ "telegram_prompt_context_cleanup",
+ prunedContexts.count,
+ );
+ }
} catch (err) {
const { getEvent } = await import("@/lib/logging/context");
getEvent()?.addWarning(`telegram-scheduled-cleanup failed: ${err}`);
diff --git a/src/lib/mood/__tests__/create-from-telegram.test.ts b/src/lib/mood/__tests__/create-from-telegram.test.ts
new file mode 100644
index 000000000..e36f4caa8
--- /dev/null
+++ b/src/lib/mood/__tests__/create-from-telegram.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const { moodEntry } = vi.hoisted(() => ({
+ moodEntry: {
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ updateMany: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: { moodEntry },
+}));
+
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserMood: vi.fn(),
+}));
+
+vi.mock("@/lib/rollups/mood-rollups", () => ({
+ recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/moodlog/push", () => ({
+ pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ getEvent: () => ({ addMeta: vi.fn() }),
+}));
+
+import {
+ logTelegramMood,
+ attachTelegramMoodNote,
+} from "@/lib/mood/create-from-telegram";
+import { invalidateUserMood } from "@/lib/cache/invalidate";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ moodEntry.findUnique.mockResolvedValue(null);
+ moodEntry.create.mockResolvedValue({
+ id: "mood-1",
+ date: "2026-06-21",
+ mood: "GUT",
+ note: null,
+ tags: null,
+ });
+ moodEntry.updateMany.mockResolvedValue({ count: 1 });
+});
+
+describe("logTelegramMood", () => {
+ it("maps a 1–5 score to the canonical mood enum and writes source=TELEGRAM", async () => {
+ const result = await logTelegramMood({
+ userId: "user-1",
+ score: 4,
+ tz: "Europe/Berlin",
+ externalId: "telegram:mood:7777:555:4",
+ });
+
+ expect(result.created).toBe(true);
+ expect(result.moodEntryId).toBe("mood-1");
+ const data = moodEntry.create.mock.calls[0][0].data;
+ expect(data.userId).toBe("user-1");
+ expect(data.mood).toBe("GUT");
+ expect(data.score).toBe(4);
+ expect(data.source).toBe("TELEGRAM");
+ expect(data.externalId).toBe("telegram:mood:7777:555:4");
+ expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
+ });
+
+ it("is idempotent on externalId — a redelivered tap does not duplicate", async () => {
+ moodEntry.findUnique.mockResolvedValueOnce({ id: "mood-existing" });
+ const result = await logTelegramMood({
+ userId: "user-1",
+ score: 2,
+ tz: null,
+ externalId: "telegram:mood:7777:555:2",
+ });
+ expect(result.created).toBe(false);
+ expect(result.moodEntryId).toBe("mood-existing");
+ expect(moodEntry.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects an invalid score", async () => {
+ await expect(
+ logTelegramMood({
+ userId: "user-1",
+ score: 7,
+ tz: null,
+ externalId: "x",
+ }),
+ ).rejects.toThrow(/Invalid mood score/);
+ });
+});
+
+describe("attachTelegramMoodNote", () => {
+ it("updates the note scoped to the user + entry and busts the cache", async () => {
+ const ok = await attachTelegramMoodNote({
+ userId: "user-1",
+ moodEntryId: "mood-1",
+ note: "rough night",
+ });
+ expect(ok).toBe(true);
+ const arg = moodEntry.updateMany.mock.calls[0][0];
+ expect(arg.where).toEqual({
+ id: "mood-1",
+ userId: "user-1",
+ deletedAt: null,
+ });
+ expect(arg.data.note).toBe("rough night");
+ expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
+ });
+
+ it("returns false when the entry does not belong to the user", async () => {
+ moodEntry.updateMany.mockResolvedValueOnce({ count: 0 });
+ const ok = await attachTelegramMoodNote({
+ userId: "user-1",
+ moodEntryId: "foreign",
+ note: "x",
+ });
+ expect(ok).toBe(false);
+ });
+});
diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts
new file mode 100644
index 000000000..d6610fe15
--- /dev/null
+++ b/src/lib/mood/create-from-telegram.ts
@@ -0,0 +1,142 @@
+/**
+ * v1.19.0 — server-authoritative mood logging from the Telegram bot.
+ *
+ * The HTTP mood-create path (`POST /api/mood-entries`) is bound to a
+ * cookie/Bearer session via `requireAuth`; the Telegram webhook resolves
+ * the user from the linked chat instead. This helper holds the shared
+ * write so the two entry points agree on the canonical shape: the same
+ * `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz`
+ * anchoring, the same cache invalidation + DAY-rollup recompute + MoodLog
+ * reverse-sync push the route fires. `source` is pinned to "TELEGRAM"
+ * (already an accepted free-text source value, no enum migration needed).
+ *
+ * The `userId` is NEVER taken from the Telegram payload — the caller
+ * passes the id it resolved from the stored `telegramChatId` binding.
+ */
+import type { Prisma, PrismaClient } from "@/generated/prisma/client";
+import { prisma as defaultPrisma } from "@/lib/db";
+import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
+import { getScoreForMood } from "@/lib/validations/moodlog";
+import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { invalidateUserMood } from "@/lib/cache/invalidate";
+import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
+import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
+import { getEvent } from "@/lib/logging/context";
+
+export interface TelegramMoodResult {
+ /** True when a new row was written; false when the idempotency key already had one. */
+ created: boolean;
+ moodEntryId: string;
+ score: number;
+}
+
+/**
+ * Log a mood entry on behalf of a Telegram-linked user.
+ *
+ * Idempotent on `externalId` (the per-tap key the caller derives from
+ * `telegram:mood:::`): a redelivered callback
+ * update converges onto the same row via the NULL-distinct
+ * `(userId, source, externalId)` unique instead of minting a duplicate.
+ */
+export async function logTelegramMood(input: {
+ userId: string;
+ score: number;
+ tz: string | null;
+ /** Stable per-tap id for Telegram redelivery dedup. */
+ externalId: string;
+ note?: string | null;
+ client?: PrismaClient | Prisma.TransactionClient;
+}): Promise {
+ const prisma = (input.client ?? defaultPrisma) as PrismaClient;
+ const mood = MOOD_ENUM_BY_SCORE[input.score];
+ if (!mood) {
+ throw new Error(`Invalid mood score: ${input.score}`);
+ }
+ const score = getScoreForMood(mood);
+ const moodLoggedAt = new Date();
+ const tz = input.tz ?? DEFAULT_TIMEZONE;
+ const date = moodDateKey(moodLoggedAt, tz);
+
+ const existing = await prisma.moodEntry.findUnique({
+ where: {
+ userId_source_externalId: {
+ userId: input.userId,
+ source: "TELEGRAM",
+ externalId: input.externalId,
+ },
+ },
+ select: { id: true },
+ });
+
+ if (existing) {
+ return { created: false, moodEntryId: existing.id, score };
+ }
+
+ const entry = await prisma.moodEntry.create({
+ data: {
+ userId: input.userId,
+ date,
+ tz,
+ mood,
+ score,
+ note: input.note ?? null,
+ source: "TELEGRAM",
+ externalId: input.externalId,
+ moodLoggedAt,
+ },
+ select: { id: true, date: true, mood: true, note: true, tags: true },
+ });
+
+ invalidateUserMood(input.userId);
+
+ // Best-effort rollup refresh — a cache tier, never a write-path invariant.
+ try {
+ await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt);
+ } catch (rollupErr) {
+ getEvent()?.addMeta(
+ "telegram_mood_rollup_failed",
+ rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
+ );
+ }
+
+ // Reverse-sync to MoodLog (fire-and-forget; never throws).
+ void pushMoodEntriesToMoodLog(input.userId, [
+ {
+ date: entry.date,
+ moodLoggedAt,
+ mood: entry.mood,
+ note: entry.note ?? null,
+ tags: entry.tags,
+ source: "TELEGRAM",
+ },
+ ]).catch(() => {});
+
+ return { created: true, moodEntryId: entry.id, score };
+}
+
+/**
+ * Attach a free-text note to an existing Telegram-logged mood entry and
+ * refresh the derived caches/rollups. Bounded to 500 chars (the same cap
+ * the mood-create Zod schema enforces) so an oversized reply can't bloat
+ * the column.
+ */
+export async function attachTelegramMoodNote(input: {
+ userId: string;
+ moodEntryId: string;
+ note: string;
+ client?: PrismaClient | Prisma.TransactionClient;
+}): Promise {
+ const prisma = (input.client ?? defaultPrisma) as PrismaClient;
+ const note = input.note.slice(0, 500);
+ const updated = await prisma.moodEntry.updateMany({
+ where: {
+ id: input.moodEntryId,
+ userId: input.userId,
+ deletedAt: null,
+ },
+ data: { note },
+ });
+ if (updated.count === 0) return false;
+ invalidateUserMood(input.userId);
+ return true;
+}
diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts
index 69e07e44e..9439062dd 100644
--- a/src/lib/notifications/senders/__tests__/telegram.test.ts
+++ b/src/lib/notifications/senders/__tests__/telegram.test.ts
@@ -16,6 +16,7 @@ const deleteMessageMock = vi.fn();
const findUniqueMock = vi.fn();
const deleteMock = vi.fn();
const upsertMock = vi.fn();
+const userFindUniqueMock = vi.fn();
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args),
@@ -24,6 +25,9 @@ vi.mock("@/lib/telegram", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
+ user: {
+ findUnique: (...args: unknown[]) => userFindUniqueMock(...args),
+ },
telegramReminderMessage: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
delete: (...args: unknown[]) => deleteMock(...args),
@@ -40,6 +44,13 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
}));
+const scheduleTelegramAutoDeleteMock = vi.fn();
+vi.mock("@/lib/telegram-cleanup", () => ({
+ scheduleTelegramAutoDelete: (...args: unknown[]) =>
+ scheduleTelegramAutoDeleteMock(...args),
+ TELEGRAM_AUTO_DELETE_DELAY_MS: 30 * 60 * 1000,
+}));
+
import { sendViaTelegram } from "../telegram";
const config = { botToken: "bot-token", chatId: "chat-1" };
@@ -68,6 +79,8 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
findUniqueMock.mockResolvedValue(null);
deleteMock.mockResolvedValue(undefined);
upsertMock.mockResolvedValue(undefined);
+ userFindUniqueMock.mockResolvedValue({ locale: "en" });
+ scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -141,3 +154,83 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => {
expect(deleteMock).not.toHaveBeenCalled();
});
});
+
+describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => {
+ beforeEach(() => {
+ sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 });
+ deleteMessageMock.mockResolvedValue(undefined);
+ findUniqueMock.mockResolvedValue(null);
+ upsertMock.mockResolvedValue(undefined);
+ userFindUniqueMock.mockResolvedValue({ locale: "en" });
+ scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("attaches a 1–5 mood keyboard + note/later row on MOOD_REMINDER", async () => {
+ await sendViaTelegram(config, {
+ eventType: "MOOD_REMINDER",
+ userId: "user-1",
+ title: "t",
+ message: "How are you feeling today?",
+ });
+
+ const opts = sendTelegramMessageMock.mock.calls[0][3];
+ const rows = opts.replyMarkup.inline_keyboard;
+ expect(
+ rows[0].map((b: { callback_data: string }) => b.callback_data),
+ ).toEqual(["mood:1", "mood:2", "mood:3", "mood:4", "mood:5"]);
+ expect(
+ rows[1].map((b: { callback_data: string }) => b.callback_data),
+ ).toEqual(["mood_note", "mood_later:120"]);
+ });
+
+ it("schedules the unanswered mood prompt for ~30-min self-clean", async () => {
+ await sendViaTelegram(config, {
+ eventType: "MOOD_REMINDER",
+ userId: "user-1",
+ title: "t",
+ message: "m",
+ });
+ expect(scheduleTelegramAutoDeleteMock).toHaveBeenCalledWith(
+ "user-1",
+ "chat-1",
+ [999],
+ );
+ });
+
+ it("attaches a done/later keyboard on MEASUREMENT_REMINDER with a reminderId", async () => {
+ await sendViaTelegram(config, {
+ eventType: "MEASUREMENT_REMINDER",
+ userId: "user-1",
+ title: "t",
+ message: "m",
+ metadata: { reminderId: "rem-1" },
+ });
+ const opts = sendTelegramMessageMock.mock.calls[0][3];
+ const row = opts.replyMarkup.inline_keyboard[0];
+ expect(row.map((b: { callback_data: string }) => b.callback_data)).toEqual([
+ "measure_done:rem-1",
+ "measure_later:rem-1:180",
+ ]);
+ expect(scheduleTelegramAutoDeleteMock).toHaveBeenCalledWith(
+ "user-1",
+ "chat-1",
+ [999],
+ );
+ });
+
+ it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => {
+ await sendViaTelegram(config, {
+ eventType: "MEASUREMENT_REMINDER",
+ userId: "user-1",
+ title: "t",
+ message: "m",
+ });
+ const opts = sendTelegramMessageMock.mock.calls[0][3];
+ expect(opts.replyMarkup).toBeUndefined();
+ expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/notifications/senders/telegram-keyboards.ts b/src/lib/notifications/senders/telegram-keyboards.ts
new file mode 100644
index 000000000..3247aecf0
--- /dev/null
+++ b/src/lib/notifications/senders/telegram-keyboards.ts
@@ -0,0 +1,73 @@
+/**
+ * v1.19.0 — inline keyboards for the interactive mood + measurement
+ * reminders. Mirrors the medication keyboard pattern: callback_data values
+ * are STABLE, locale-independent identifiers (decoupled from the button
+ * label, which is localised). The webhook parses these ids; never the label.
+ *
+ * Tone is restrained and clinical — plain "1 2 3 4 5", no emoji, no
+ * gamification (no streaks/points). The mood scale matches the app's
+ * canonical 1..5 (`MOOD_SCORE_BY_ENUM`).
+ */
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { Locale } from "@/lib/i18n/config";
+
+export interface TelegramInlineKeyboard {
+ inline_keyboard: { text: string; callback_data: string }[][];
+}
+
+/**
+ * Mood reminder keyboard: a 1–5 row, then a "Note…" / "Later" row.
+ *
+ * mood: → log a MoodEntry with that score
+ * mood_note → open a force_reply prompt to attach a note
+ * mood_later: → re-enqueue the nudge after minutes
+ */
+export function buildMoodKeyboard(locale: Locale): TelegramInlineKeyboard {
+ const { t } = getServerTranslator(locale);
+ return {
+ inline_keyboard: [
+ [1, 2, 3, 4, 5].map((score) => ({
+ text: String(score),
+ callback_data: `mood:${score}`,
+ })),
+ [
+ {
+ text: t("telegram.buttonMoodNote"),
+ callback_data: "mood_note",
+ },
+ {
+ text: t("telegram.buttonLater"),
+ callback_data: "mood_later:120",
+ },
+ ],
+ ],
+ };
+}
+
+/**
+ * Measurement (Vorsorge) reminder keyboard: "Done" / "Later". No value
+ * capture — completion + snooze only, to keep it restrained.
+ *
+ * measure_done: → satisfy the reminder
+ * measure_later:: → re-enqueue after minutes
+ */
+export function buildMeasurementKeyboard(
+ reminderId: string,
+ locale: Locale,
+): TelegramInlineKeyboard {
+ const { t } = getServerTranslator(locale);
+ return {
+ inline_keyboard: [
+ [
+ {
+ text: t("telegram.buttonDone"),
+ callback_data: `measure_done:${reminderId}`,
+ },
+ {
+ text: t("telegram.buttonLater"),
+ callback_data: `measure_later:${reminderId}:180`,
+ },
+ ],
+ ],
+ };
+}
diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts
index 7cf444128..b31f9f6de 100644
--- a/src/lib/notifications/senders/telegram.ts
+++ b/src/lib/notifications/senders/telegram.ts
@@ -9,6 +9,36 @@ import { prisma } from "@/lib/db";
import type { ReminderPhase } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
+import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
+import {
+ buildMoodKeyboard,
+ buildMeasurementKeyboard,
+} from "@/lib/notifications/senders/telegram-keyboards";
+import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
+
+/**
+ * v1.19.0 — resolve the bot locale for an interactive reminder. The
+ * sender knows the user via `payload.userId`; the keyboard labels need
+ * the user's locale. Defaults to "de" (the historical bot default) when
+ * unset, matching the webhook's `resolveBotLocale`.
+ */
+async function resolveSenderLocale(userId: string): Promise {
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { locale: true },
+ });
+ const value = user?.locale;
+ if (value && (locales as readonly string[]).includes(value)) {
+ return value as Locale;
+ }
+ } catch {
+ // Fall through to the default on any read failure.
+ }
+ return (locales as readonly string[]).includes("de")
+ ? ("de" as Locale)
+ : defaultLocale;
+}
/**
* Telegram sender result. Inherits from `SendOutcome` so the dispatcher
@@ -113,9 +143,32 @@ export async function sendViaTelegram(
);
}
+ // v1.19.0 — interactive mood + measurement reminders. The mood prompt
+ // ships a 1–5 scale + note/later row; the measurement prompt ships a
+ // done/later row (no value capture). Labels are localised; callback_data
+ // ids are stable and locale-independent. Both prompts schedule their own
+ // ~30-min auto-delete below so an ignored nudge self-cleans.
+ const reminderId = payload.metadata?.reminderId as string | undefined;
+ let interactiveKeyboard:
+ | { inline_keyboard: { text: string; callback_data: string }[][] }
+ | undefined;
+ if (!replyMarkup) {
+ if (payload.eventType === "MOOD_REMINDER") {
+ interactiveKeyboard = buildMoodKeyboard(
+ await resolveSenderLocale(payload.userId),
+ );
+ } else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) {
+ interactiveKeyboard = buildMeasurementKeyboard(
+ reminderId,
+ await resolveSenderLocale(payload.userId),
+ );
+ }
+ }
+
// Build reply markup
const keyboard =
replyMarkup ??
+ interactiveKeyboard ??
(payload.eventType === "MEDICATION_REMINDER" && medicationId
? {
inline_keyboard: [
@@ -192,6 +245,17 @@ export async function sendViaTelegram(
}
}
+ // v1.19.0 — schedule the interactive prompt's own ~30-min self-clean so
+ // an UNANSWERED mood / measurement nudge never lingers in the chat (the
+ // medication path re-cleans on slot re-send; these have no re-send). A
+ // tapped prompt is deleted immediately by the webhook callback handler;
+ // this row is the fallback and is harmless once the message is gone.
+ if (result.ok && result.messageId && interactiveKeyboard) {
+ await scheduleTelegramAutoDelete(payload.userId, config.chatId, [
+ result.messageId,
+ ]);
+ }
+
if (result.ok) {
recordPushAttempt({
userId: payload.userId,
diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts
index 6b7a1f4a7..f9dd8b9be 100644
--- a/src/lib/observability/redact-payload.ts
+++ b/src/lib/observability/redact-payload.ts
@@ -72,6 +72,15 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
// the secret redacts instead of landing verbatim. The Live Activity push
// token is already covered by `/token/i`.
/headervalue/i,
+ // v1.19.0 — the Telegram chat id. It is the de-facto account-binding
+ // credential for the bot (the webhook resolves the user by stored
+ // `telegramChatId`) and a stable personal identifier, so it belongs out
+ // of wide-event excerpts. Matches `telegramChatId` / `telegram_chat_id`
+ // / `chatId` / `chat_id`. The bot token is already covered by `/token/i`.
+ /telegramchatid/i,
+ /telegram[_-]?chat[_-]?id/i,
+ /chatid/i,
+ /chat[_-]id/i,
];
const REDACTED = "[redacted]";
diff --git a/src/lib/telegram-cleanup.ts b/src/lib/telegram-cleanup.ts
new file mode 100644
index 000000000..92a8ac659
--- /dev/null
+++ b/src/lib/telegram-cleanup.ts
@@ -0,0 +1,49 @@
+/**
+ * v1.19.0 — shared self-clean scheduling for the Telegram bot.
+ *
+ * The interactive mood + measurement flows ride the existing
+ * `TelegramScheduledDeletion` table + the ~15-min pg-boss sweep
+ * (`cleanupScheduledTelegramDeletions`). This helper centralises the
+ * "schedule these message ids for deletion in ~30 minutes" write so the
+ * webhook (free-text replies) and the sender (unanswered prompts) agree on
+ * the window. The sweep deletes the bot's own prompt AND, where its id was
+ * captured, the user's reply — both within Telegram's 48-hour window.
+ */
+import type { Prisma, PrismaClient } from "@/generated/prisma/client";
+import { prisma as defaultPrisma } from "@/lib/db";
+
+/**
+ * Auto-delete delay for interactive-flow prompts + answers. ~30 minutes
+ * keeps the chat clean and unbiased while staying well inside Telegram's
+ * 48-hour deletion ceiling. The sweep ticks every ~15 min, so real
+ * deletion lands at 30–45 min — invisible slack, no dedicated queue.
+ */
+export const TELEGRAM_AUTO_DELETE_DELAY_MS = 30 * 60 * 1000;
+
+/**
+ * Schedule one or more Telegram message ids for best-effort deletion after
+ * `TELEGRAM_AUTO_DELETE_DELAY_MS`. Best-effort: a write failure must never
+ * break the inbound/outbound flow it rides on.
+ */
+export async function scheduleTelegramAutoDelete(
+ userId: string,
+ chatId: string,
+ messageIds: number[],
+ client: PrismaClient | Prisma.TransactionClient = defaultPrisma,
+): Promise {
+ const ids = messageIds.filter((id): id is number => Number.isFinite(id));
+ if (ids.length === 0) return;
+ const deleteAfter = new Date(Date.now() + TELEGRAM_AUTO_DELETE_DELAY_MS);
+ try {
+ await client.telegramScheduledDeletion.createMany({
+ data: ids.map((messageId) => ({
+ userId,
+ chatId,
+ messageId,
+ deleteAfter,
+ })),
+ });
+ } catch {
+ // Best-effort — the message simply lingers until the next send re-cleans.
+ }
+}
diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts
index a9729de9e..2bbc95320 100644
--- a/src/lib/telegram.ts
+++ b/src/lib/telegram.ts
@@ -20,9 +20,20 @@ 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;
+ replyMarkup?: TelegramReplyMarkup | TelegramForceReply;
}
async function telegramApiRequest(