mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge w11b/telegram into release/v1.19.0
This commit is contained in:
commit
0045188899
20 files changed
+1187
-16
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]";
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user