Merge w11b/telegram into release/v1.19.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 11:14:24 +02:00
commit 0045188899
20 files changed
+1187 -16

No files matched your search

+11
View File
@@ -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": "<b>Welches Medikament eingenommen?</b>",
"cancelButton": "Abbrechen",
+11
View File
@@ -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": "<b>Which medication did you take?</b>",
"cancelButton": "Cancel",
+11
View File
@@ -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": "<b>¿Qué medicamento has tomado?</b>",
"cancelButton": "Cancelar",
+11
View File
@@ -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": "<b>Quel médicament as-tu pris ?</b>",
"cancelButton": "Annuler",
+11
View File
@@ -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": "<b>Quale farmaco hai assunto?</b>",
"cancelButton": "Annulla",
+11
View File
@@ -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": "<b>Który lek został przyjęty?</b>",
"cancelButton": "Anuluj",
@@ -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");
+27
View File
@@ -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 {
@@ -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) ||
@@ -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([]);
@@ -106,6 +106,22 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
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}`);
@@ -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);
});
});
+142
View File
@@ -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:<chatId>:<messageId>:<score>`): 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<TelegramMoodResult> {
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<boolean> {
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;
}
@@ -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();
});
});
@@ -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:<score> → log a MoodEntry with that score
* mood_note → open a force_reply prompt to attach a note
* mood_later:<min> → re-enqueue the nudge after <min> 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:<reminderId> → satisfy the reminder
* measure_later:<reminderId>:<min> → re-enqueue after <min> 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`,
},
],
],
};
}
+64
View File
@@ -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<Locale> {
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,
+9
View File
@@ -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]";
+49
View File
@@ -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<void> {
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.
}
}
+12 -1
View File
@@ -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(