feat(telegram): unique chat binding and numeric measurement capture

Two related changes to the Telegram bot's data path.

One chat ⇄ one account. `User.telegramChatId` is now UNIQUE (migration
0189, defensive pre-clean of any pre-existing duplicate binding). The
inbound webhook resolves the user from the chat id, so a chat shared
across two accounts would route a reply or button tap ambiguously. The
index is over a nullable column, so an unlinked account (null chat id)
never collides — only two accounts binding the same non-null chat id are
rejected.

Numeric measurement capture. A numeric reply to a measurement reminder is
now captured as a Measurement (`source = TELEGRAM`, added to the enum in
0189). On "Erledigt" for a reminder that names a single-value metric, the
bot offers an optional value prompt via force_reply; a numeric reply is
written through the shared `logTelegramMeasurement` helper — same range
guard, canonical unit, cache + rollup refresh as the manual route. The
reply binds to the reminder and user through a TelegramPromptContext keyed
on the prompt message id (the same strict chat/message binding the
mood-note path uses); the userId is never read from the payload. Comma
decimals are tolerated; non-numeric and out-of-range replies get a calm
hint and write nothing; the reply self-cleans on the ~30-min sweep. Blood
pressure (two values) is out of scope for the single-numeric path and
keeps its satisfy-only flow.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 21:06:42 +02:00
1 parent 5c0583133d
commit 9c8fb8c7c8
13 files changed
+562 -11

No files matched your search

+101 -4
View File
@@ -33,6 +33,10 @@ import {
} from "@/lib/mood/create-from-telegram";
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import {
isTelegramCapturableType,
logTelegramMeasurement,
} from "@/lib/measurements/create-from-telegram";
interface TelegramUpdate {
update_id: number;
@@ -776,13 +780,15 @@ async function handleCallback(update: TelegramUpdate) {
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.
// v1.19.0 — mark a Vorsorge/measurement reminder done; 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,
// v1.19.2 — the target metric; drives the optional numeric capture.
measurementType: true,
intervalDays: true,
rrule: true,
anchorDate: true,
@@ -809,6 +815,48 @@ async function handleCallback(update: TelegramUpdate) {
if (messageId) {
await deleteMessage(botToken, chatId, messageId);
}
// v1.19.2 — optional numeric value capture. When the reminder names a
// single-value metric (BP and free-text Vorsorge are excluded), offer a
// force-reply prompt; a numeric reply is captured as a Measurement
// (source=TELEGRAM) in `handleTextMessage`. The reply binds to THIS
// reminder + user through a TelegramPromptContext keyed on the prompt
// message id, the SAME strict chat/message binding the mood-note path
// uses — the userId is never read from the inbound payload. Optional and
// self-cleaning: ignoring the prompt leaves the cadence already
// satisfied, and the prompt is swept on the ~30-min auto-delete.
if (isTelegramCapturableType(reminder.measurementType)) {
const valuePrompt = await sendTelegramMessage(
botToken,
chatId,
t("telegram.measureValuePrompt"),
{
replyMarkup: {
force_reply: true,
input_field_placeholder: t("telegram.buttonMeasureValue"),
},
},
);
if (valuePrompt.ok && valuePrompt.messageId) {
try {
await prisma.telegramPromptContext.create({
data: {
userId: user.id,
chatId,
promptMsgId: valuePrompt.messageId,
kind: "measure_value",
refId: reminder.id,
// Same ~30-min window as the deletion sweep; a later reply is
// treated as expired.
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
},
});
} catch {
// Best-effort — without the context row the value simply can't bind.
}
await scheduleAutoDelete(user.id, chatId, [valuePrompt.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
@@ -892,7 +940,11 @@ async function handleTextMessage(update: TelegramUpdate) {
expiresAt: true,
},
});
if (context && context.userId === user.id && context.kind === "mood_note") {
if (
context &&
context.userId === user.id &&
(context.kind === "mood_note" || context.kind === "measure_value")
) {
// Consume the context row regardless of outcome so a reply can't be
// replayed onto the entry twice.
await prisma.telegramPromptContext
@@ -903,7 +955,9 @@ async function handleTextMessage(update: TelegramUpdate) {
const resp = await sendTelegramMessage(
botToken,
chatId,
t("telegram.moodNoteExpired"),
context.kind === "measure_value"
? t("telegram.measureValueExpired")
: t("telegram.moodNoteExpired"),
);
const toDelete = [userMsgId, replyToId, resp.messageId].filter(
(id): id is number => id != null,
@@ -913,6 +967,49 @@ async function handleTextMessage(update: TelegramUpdate) {
}
return;
}
if (context.kind === "measure_value") {
// v1.19.2 — numeric value capture for a measurement reminder. The
// reminder named on the context row carries the expected metric; the
// userId is the chat-resolved one (never the payload). A non-numeric
// / out-of-range reply gets a calm hint, not a write. The reply, the
// prompt, and the confirmation all self-clean on the ~30-min sweep.
const reminder = await prisma.measurementReminder.findFirst({
where: { id: context.refId, userId: user.id, deletedAt: null },
select: { measurementType: true },
});
let confirmation: string;
if (!isTelegramCapturableType(reminder?.measurementType)) {
confirmation = t("telegram.measurementNotFound");
} else {
const externalId = `telegram:measure:${chatId}:${replyToId}`.slice(
0,
120,
);
const result = await logTelegramMeasurement({
userId: user.id,
type: reminder.measurementType,
rawText: text,
tz: user.timezone,
externalId,
});
confirmation =
result.status === "ok"
? t("telegram.measureValueSaved")
: result.status === "out_of_range"
? t("telegram.measureValueOutOfRange")
: t("telegram.measureValueInvalid");
}
const resp = await sendTelegramMessage(botToken, chatId, confirmation);
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,