feat(telegram): log mood and act on measurement reminders in chat

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

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

No files matched your search

@@ -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();
});
});
+253 -15
View File
@@ -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<void> {
}
}
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<void> {
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) ||