mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(telegram): log mood and act on measurement reminders in chat
Extend the self-cleaning Telegram bot beyond medication. A mood reminder now carries a 1-5 inline keyboard plus a "Note..." force-reply and a "Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side, user resolved from the linked chat, idempotent on a per-tap externalId) and confirms with a calm callback toast, no chat echo. An optional free-text note attaches to that entry via the prompt-context correlation. A measurement reminder carries "Done" (satisfies the cadence) and "Later" (pushes nextDueAt forward) — completion and snooze only, no value capture. Prompts and replies self-clean in ~30 minutes: the window drops from 1h and moves to a shared helper, the sender schedules an unanswered prompt's deletion at send time, and the existing 15-min sweep also prunes expired prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual + rate-limit-before-secret posture; every write callback authenticates via the stored chat binding. The Telegram chat id is added to the egress redaction denylist.
This commit is contained in:
1 parent
7f7ee2c013
commit
b24a893b66
18 files changed
+1140
-16
No files matched your search
@@ -28,10 +28,36 @@ vi.mock("@/lib/db", () => ({
|
||||
telegramScheduledDeletion: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
telegramPromptContext: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
moodReminderDispatch: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
measurementReminder: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/mood/create-from-telegram", () => ({
|
||||
logTelegramMood: vi.fn(),
|
||||
attachTelegramMoodNote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telegram-cleanup", () => ({
|
||||
scheduleTelegramAutoDelete: vi.fn().mockResolvedValue(undefined),
|
||||
TELEGRAM_AUTO_DELETE_DELAY_MS: 30 * 60 * 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
|
||||
satisfyReminder: vi.fn().mockResolvedValue({ satisfied: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => `decrypted:${value}`),
|
||||
}));
|
||||
@@ -77,6 +103,12 @@ import {
|
||||
sendTelegramMessage,
|
||||
} from "@/lib/telegram";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
logTelegramMood,
|
||||
attachTelegramMoodNote,
|
||||
} from "@/lib/mood/create-from-telegram";
|
||||
import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
|
||||
@@ -155,6 +187,23 @@ beforeEach(() => {
|
||||
messageId: 999,
|
||||
ok: true,
|
||||
} as never);
|
||||
// v1.19.0 interactive mood + measurement defaults.
|
||||
vi.mocked(logTelegramMood).mockResolvedValue({
|
||||
created: true,
|
||||
moodEntryId: "mood-1",
|
||||
score: 4,
|
||||
} as never);
|
||||
vi.mocked(attachTelegramMoodNote).mockResolvedValue(true as never);
|
||||
vi.mocked(scheduleTelegramAutoDelete).mockResolvedValue(undefined as never);
|
||||
vi.mocked(satisfyReminder).mockResolvedValue({ satisfied: true } as never);
|
||||
vi.mocked(prisma.telegramPromptContext.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.telegramPromptContext.delete).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodReminderDispatch.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurementReminder.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -834,3 +883,177 @@ describe("Telegram webhook — GET verification", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — interactive mood (v1.19.0)", () => {
|
||||
it("logs a MoodEntry on a 1–5 tap, scoped to the linked chat's user", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("mood:4")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(logTelegramMood).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(logTelegramMood).mock.calls[0][0];
|
||||
// userId resolved from the linked chat (TG_USER), never the payload.
|
||||
expect(arg.userId).toBe("user-1");
|
||||
expect(arg.score).toBe(4);
|
||||
// Stable per-tap idempotency id keyed on chat + message + score.
|
||||
expect(arg.externalId).toContain("telegram:mood:7777:555:4");
|
||||
// Calm callback toast, prompt deleted (no chat echo of the score).
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalled();
|
||||
expect(deleteMessage).toHaveBeenCalledWith(
|
||||
"decrypted:ENC(token-blob)",
|
||||
"7777",
|
||||
555,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range mood score without writing", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("mood:9")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a mood tap from an unlinked / foreign chat", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
tgRequest(
|
||||
callbackUpdate("mood:5", {
|
||||
from: { id: 4242 },
|
||||
message: { message_id: 1, chat: { id: 4242 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
expect(answerTelegramCallbackQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a force_reply note prompt and records its prompt context", async () => {
|
||||
vi.mocked(sendTelegramMessage).mockResolvedValueOnce({
|
||||
messageId: 4321,
|
||||
ok: true,
|
||||
} as never);
|
||||
await POST(tgRequest(callbackUpdate("mood:3")));
|
||||
const sendArgs = vi.mocked(sendTelegramMessage).mock.calls[0];
|
||||
// force_reply markup opens the user's reply box pre-targeted at the bot.
|
||||
expect(sendArgs[3]).toMatchObject({
|
||||
replyMarkup: { force_reply: true },
|
||||
});
|
||||
expect(prisma.telegramPromptContext.create).toHaveBeenCalledTimes(1);
|
||||
const ctx = vi.mocked(prisma.telegramPromptContext.create).mock.calls[0][0];
|
||||
expect(ctx.data).toMatchObject({
|
||||
userId: "user-1",
|
||||
chatId: "7777",
|
||||
promptMsgId: 4321,
|
||||
kind: "mood_note",
|
||||
refId: "mood-1",
|
||||
});
|
||||
// The note prompt is scheduled to self-clean (~30 min).
|
||||
expect(scheduleTelegramAutoDelete).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"7777",
|
||||
[4321],
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches a free-text reply to the just-logged mood entry", async () => {
|
||||
vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({
|
||||
id: "ctx-1",
|
||||
userId: "user-1",
|
||||
kind: "mood_note",
|
||||
refId: "mood-1",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 900,
|
||||
message: {
|
||||
message_id: 60,
|
||||
text: "Slept badly",
|
||||
chat: { id: 7777 },
|
||||
reply_to_message: { message_id: 4321 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(attachTelegramMoodNote).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
moodEntryId: "mood-1",
|
||||
note: "Slept badly",
|
||||
});
|
||||
// The prompt context row is consumed so a reply can't be replayed.
|
||||
expect(prisma.telegramPromptContext.delete).toHaveBeenCalledWith({
|
||||
where: { id: "ctx-1" },
|
||||
});
|
||||
// Note, prompt, and confirmation are scheduled to self-clean.
|
||||
expect(scheduleTelegramAutoDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach a note when the reply context belongs to another user", async () => {
|
||||
vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({
|
||||
id: "ctx-2",
|
||||
userId: "someone-else",
|
||||
kind: "mood_note",
|
||||
refId: "mood-99",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 901,
|
||||
message: {
|
||||
message_id: 61,
|
||||
text: "leak attempt",
|
||||
chat: { id: 7777 },
|
||||
reply_to_message: { message_id: 4321 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(attachTelegramMoodNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mood_later clears today's dispatch ledger and deletes the prompt", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("mood_later:120")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodReminderDispatch.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalled();
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — interactive measurement reminder (v1.19.0)", () => {
|
||||
it("measure_done satisfies the reminder via the shared primitive", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({
|
||||
id: "rem-1",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
notifyHour: 9,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
} as never);
|
||||
const res = await POST(tgRequest(callbackUpdate("measure_done:rem-1")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(satisfyReminder).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("measure_done on a foreign reminder is a no-op", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("measure_done:rem-x")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(satisfyReminder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("measure_later pushes nextDueAt forward (one-shot snooze)", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({
|
||||
id: "rem-2",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
tgRequest(callbackUpdate("measure_later:rem-2:180")),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementReminder.update).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(prisma.measurementReminder.update).mock.calls[0][0];
|
||||
expect(arg.where).toEqual({ id: "rem-2" });
|
||||
expect(arg.data.nextDueAt).toBeInstanceOf(Date);
|
||||
expect(deleteMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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) ||
|
||||
|
||||
Reference in new issue
Block a user