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(