Files
iistwin/server/medschedule/bot.routes.ts

489 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Express, Request, Response } from "express";
import crypto from "crypto";
import { medScheduleStorage } from "./storage";
import { sendBotMessage, sendBotPhoto } from "./bot-sender";
import { storage } from "../storage";
import { hashPassword } from "../utils/password";
import { parseMedicationFromText, parsePurchaseFromText, parseMedicalRecordFromText, analyzeMedicalImageWithN8n } from "./ai.service";
import { db } from "../db";
import { users } from "@shared/schema";
import { eq, and } from "drizzle-orm";
import type { BotPlatform } from "./bot-sender";
interface NormalizedMessage {
platform: BotPlatform;
chatId: string;
from: {
id: string;
username?: string;
firstName?: string;
lastName?: string;
};
text?: string;
photo?: { fileId: string }[];
}
const TELEGRAM_API_BASE = "https://api.telegram.org/bot";
const MAX_API_BASE = process.env.MAX_BOT_API_BASE || "https://api.max.ru/bot";
function normalizeTelegramUpdate(body: any): NormalizedMessage | null {
const msg = body?.message;
if (!msg) return null;
return {
platform: "telegram",
chatId: String(msg.chat?.id),
from: {
id: String(msg.from?.id),
username: msg.from?.username,
firstName: msg.from?.first_name,
lastName: msg.from?.last_name,
},
text: msg.text,
photo: msg.photo?.map((p: any) => ({ fileId: p.file_id })),
};
}
function normalizeMaxUpdate(body: any): NormalizedMessage | null {
// MAX Bot API — placeholder, структура уточняется
const msg = body?.message;
if (!msg) return null;
return {
platform: "max",
chatId: String(msg.chat?.id),
from: {
id: String(msg.from?.id),
username: msg.from?.username,
firstName: msg.from?.first_name,
lastName: msg.from?.last_name,
},
text: msg.text,
photo: msg.photo?.map((p: any) => ({ fileId: p.file_id })),
};
}
function verifyTelegramSecret(token: string, body: any): boolean {
const secret = process.env.TELEGRAM_BOT_WEBHOOK_SECRET;
if (!secret) return true; // если не настроен — пропускаем
const check = crypto.createHmac("sha256", secret).update(JSON.stringify(body)).digest("hex");
return token === check;
}
function verifyMaxSecret(token: string, _body: any): boolean {
const secret = process.env.MAX_BOT_WEBHOOK_SECRET;
if (!secret) return true;
return token === secret;
}
async function findOrCreateUserFromBot(platform: BotPlatform, msg: NormalizedMessage): Promise<any> {
const orgId = await medScheduleStorage.ensureMedScheduleOrganization();
const chatId = msg.chatId;
// Сначала ищем по chatId
const existingBinding =
platform === "telegram"
? await medScheduleStorage.getBotBindingByTelegramChatId(orgId, chatId)
: await medScheduleStorage.getBotBindingByMaxChatId(orgId, chatId);
if (existingBinding) {
const user = await storage.getUser(existingBinding.userId);
if (user) return user;
}
// Ищем по email
const email = `${platform}_${chatId}@medschedule.local`;
let user = await storage.getUserByEmail(email, orgId);
if (!user) {
const password = crypto.randomBytes(12).toString("hex");
user = await storage.createUser({
organizationId: orgId,
email,
passwordHash: await hashPassword(password),
firstName: msg.from.firstName || msg.from.username || (platform === "telegram" ? "Telegram" : "MAX"),
lastName: msg.from.lastName || "Пользователь",
appRole: "user",
isActive: true,
});
}
if (platform === "telegram") {
await medScheduleStorage.bindTelegram(user.id, orgId, chatId, msg.from.username);
} else {
await medScheduleStorage.bindMax(user.id, orgId, chatId, msg.from.username);
}
return user;
}
async function getUserFamily(userId: number, orgId: number) {
const groups = await medScheduleStorage.getFamilyGroupsByUser(userId, orgId);
return groups[0] ?? null;
}
async function reply(platform: BotPlatform, chatId: string, text: string) {
return sendBotMessage(platform, chatId, text, { parseMode: "HTML" });
}
async function handleCommand(platform: BotPlatform, msg: NormalizedMessage, user: any) {
const orgId = await medScheduleStorage.ensureMedScheduleOrganization();
const chatId = msg.chatId;
const text = msg.text || "";
const args = text.split(/\s+/);
const command = args[0]?.toLowerCase();
const rest = args.slice(1).join(" ").trim();
switch (command) {
case "/start":
await reply(platform, chatId, `👋 Привет! Я бот MedSchedule.\n\nКоманды:\n/join <код> — вступить в семью\n/add <текст> — добавить лекарство\n/taken <название> — отметить приём\n/cabinet — аптечка\n/buy <описание> — куплено лекарство\n/record <описание> — запись обследования\n/status — сегодняшняя сводка\n/checkin — ответить на ежедневный вопрос`);
return;
case "/join": {
const code = rest.toUpperCase();
if (!code) {
await reply(platform, chatId, "Введите код приглашения: /join ABC123");
return;
}
const group = await medScheduleStorage.getFamilyGroupByInviteCode(code, orgId);
if (!group) {
await reply(platform, chatId, "Семья с таким кодом не найдена.");
return;
}
const existing = await medScheduleStorage.getFamilyMembers(group.id, orgId);
const already = existing.find((m) => m.userId === user.id);
if (already) {
await reply(platform, chatId, `Вы уже в семье "${group.name}".`);
return;
}
await medScheduleStorage.createFamilyMember({
familyId: group.id,
userId: user.id,
profileName: `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || (platform === "telegram" ? "Telegram" : "MAX"),
role: "member",
createdBy: user.id,
});
await reply(platform, chatId, `✅ Вы вступили в семью "${group.name}".`);
return;
}
case "/add": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала создайте или вступите в семью (/join <код>).");
return;
}
const members = await medScheduleStorage.getFamilyMembers(family.id, orgId);
const targetMember = members.find((m) => m.userId === user.id) || members[0];
if (!targetMember) {
await reply(platform, chatId, "В семье нет участников.");
return;
}
if (!rest) {
await reply(platform, chatId, "Опишите лекарство: /add амоксициллин 500мг 2 раза в день 7 дней");
return;
}
const parsed = await parseMedicationFromText(orgId, rest);
if (!parsed) {
await reply(platform, chatId, "Не удалось распознать. Попробуйте формат: /add амоксициллин 500мг 2 раза в день 7 дней");
return;
}
const medication = await medScheduleStorage.createMedication({
familyId: family.id,
memberId: targetMember.id,
name: parsed.name,
dosage: parsed.dosage || null,
form: parsed.form || null,
schedule: parsed.schedule,
instructions: parsed.instructions || null,
createdBy: user.id,
});
// Генерируем логи на 14 дней
await medScheduleStorage.generateIntakeLogs(
medication.id,
new Date(),
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
);
await reply(platform, chatId, `✅ Добавлено лекарство ${medication.name}. Расписание создано.`);
return;
}
case "/taken": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
const member = (await medScheduleStorage.getFamilyMembers(family.id, orgId)).find((m) => m.userId === user.id);
if (!member) {
await reply(platform, chatId, "Вы не состоите в семье как участник.");
return;
}
const now = new Date();
const windowStart = new Date(now.getTime() - 60 * 60 * 1000);
const windowEnd = new Date(now.getTime() + 60 * 60 * 1000);
const intakes = await medScheduleStorage.getIntakesForDateRange(family.id, orgId, windowStart, windowEnd);
const target = intakes.find((i) =>
(rest && i.medication.name.toLowerCase().includes(rest.toLowerCase())) ||
i.intake.memberId === member.id
);
if (!target) {
await reply(platform, chatId, "Ближайший приём не найден.");
return;
}
await medScheduleStorage.markIntakeTaken(target.intake.id, family.id, orgId, "отмечено через бот");
await reply(platform, chatId, `✅ Отмечен приём ${target.medication.name}.`);
return;
}
case "/cabinet": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
const items = await medScheduleStorage.getCabinetItems(family.id, orgId);
if (items.length === 0) {
await reply(platform, chatId, "Аптечка пуста. Добавьте покупку: /buy парацетамол 20 шт");
return;
}
const list = items
.map((i) => `- ${i.name}${i.quantity ? ` — ${i.quantity} ${i.unit}` : ""}${i.expiryDate ? ` (годен до ${i.expiryDate})` : ""}`)
.join("\n");
await reply(platform, chatId, `💊 Аптечка:\n${list}`);
return;
}
case "/buy": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
if (!rest && !msg.photo) {
await reply(platform, chatId, "Опишите покупку: /buy парацетамол 20 шт");
return;
}
let name = rest;
let quantity = 1;
let unit = "шт";
let photoUrl: string | undefined;
if (msg.photo && msg.photo.length > 0) {
photoUrl = await resolvePhotoUrl(platform, msg.photo[msg.photo.length - 1].fileId);
}
if (rest) {
const parsed = await parsePurchaseFromText(orgId, rest);
if (parsed) {
name = parsed.name;
quantity = parsed.quantity ?? 1;
unit = parsed.unit ?? "шт";
}
}
const item = await medScheduleStorage.upsertCabinetItem(
family.id,
orgId,
{
name,
quantity,
unit,
photoUrl,
},
user.id
);
await medScheduleStorage.addCabinetStock(item!.id, family.id, orgId, quantity, "purchase", rest || undefined, user.id);
await reply(platform, chatId, `✅ Добавлено в аптечку: ${name} (${quantity} ${unit}).`);
return;
}
case "/record": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
const members = await medScheduleStorage.getFamilyMembers(family.id, orgId);
const targetMember = members.find((m) => m.userId === user.id) || members[0];
if (!targetMember) {
await reply(platform, chatId, "В семье нет участников.");
return;
}
if (!rest && !msg.photo) {
await reply(platform, chatId, "Опишите обследование или прикрепите фото: /record анализ крови, лейкоциты повышены");
return;
}
let photoUrl: string | undefined;
if (msg.photo && msg.photo.length > 0) {
photoUrl = await resolvePhotoUrl(platform, msg.photo[msg.photo.length - 1].fileId);
}
let title = rest || "Обследование";
let recordType = "doctor_note";
let aiSummary: string | null = null;
let aiFindings: any = null;
let recordDate = new Date().toISOString().split("T")[0];
let rawText = rest || null;
if (photoUrl) {
const ocr = await analyzeMedicalImageWithN8n(photoUrl);
if (ocr) {
title = ocr.title || title;
recordType = ocr.recordType || recordType;
aiSummary = ocr.summary || null;
aiFindings = ocr.findings || null;
recordDate = ocr.recordDate || recordDate;
rawText = ocr.rawText || rawText;
}
} else if (rest) {
const parsed = await parseMedicalRecordFromText(orgId, rest);
if (parsed) {
title = parsed.title;
recordType = parsed.recordType;
aiSummary = parsed.summary;
aiFindings = parsed.findings;
recordDate = parsed.recordDate || recordDate;
rawText = parsed.rawText || rawText;
}
}
await medScheduleStorage.createMedicalRecord({
familyId: family.id,
memberId: targetMember.id,
title,
recordType,
recordDate,
rawText,
aiSummary,
aiFindings,
fileUrls: photoUrl ? [photoUrl] : null,
addedBy: user.id,
});
await reply(platform, chatId, `✅ Запись "${title}" сохранена.${aiSummary ? `\nКратко: ${aiSummary.slice(0, 200)}` : ""}`);
return;
}
case "/status": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
const now = new Date();
const start = new Date(now); start.setHours(0, 0, 0, 0);
const end = new Date(now); end.setHours(23, 59, 59, 999);
const intakes = await medScheduleStorage.getIntakesForDateRange(family.id, orgId, start, end);
const pending = intakes.filter((i) => i.intake.status === "pending");
const taken = intakes.filter((i) => i.intake.status === "taken");
if (intakes.length === 0) {
await reply(platform, chatId, "На сегодня приёмов лекарств не запланировано.");
return;
}
const lines = intakes.map((i) => {
const time = i.intake.scheduledAt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" });
const status = i.intake.status === "taken" ? "✅" : i.intake.status === "skipped" ? "❌" : "⏳";
return `${status} ${time} — ${i.medication.name} (${i.member.profileName})`;
});
await reply(platform, chatId, `📋 Сводка на сегодня:\n${lines.join("\n")}\n\nПринято: ${taken.length}/${intakes.length}`);
return;
}
case "/checkin": {
const family = await getUserFamily(user.id, orgId);
if (!family) {
await reply(platform, chatId, "Сначала вступите в семью.");
return;
}
const pending = await medScheduleStorage.getPendingCheckins(family.id, orgId);
const today = pending.find((c) => c.checkInDate === new Date().toISOString().split("T")[0]);
if (!today) {
await reply(platform, chatId, "Сегодня нет активного вопроса. Он придёт утром.");
return;
}
if (!rest) {
await reply(platform, chatId, `🩺 ${today.question}\n\nОтветьте: /checkin <ваш ответ>`);
return;
}
// TODO: summarize via AI
await medScheduleStorage.submitCheckinAnswer(today.id, family.id, orgId, rest);
await reply(platform, chatId, "✅ Ответ сохранён. Спасибо!");
return;
}
default:
await reply(platform, chatId, "Неизвестная команда. Список команд: /start");
}
}
async function resolvePhotoUrl(platform: BotPlatform, fileId: string): Promise<string | undefined> {
const token = platform === "telegram" ? process.env.TELEGRAM_BOT_TOKEN : process.env.MAX_BOT_TOKEN;
if (!token) return undefined;
const base = platform === "telegram" ? `${TELEGRAM_API_BASE}${token}` : `${MAX_API_BASE}${token}`;
try {
const res = await fetch(`${base}/getFile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_id: fileId }),
});
const data = await res.json() as any;
const filePath = data?.result?.file_path;
if (!filePath) return undefined;
return platform === "telegram"
? `https://api.telegram.org/file/bot${token}/${filePath}`
: `${MAX_API_BASE}${token}/${filePath}`;
} catch (err) {
console.error("[MedSchedule Bot] resolvePhotoUrl error:", err);
return undefined;
}
}
export function registerMedScheduleBotRoutes(app: Express) {
app.post("/api/med/bot/webhook/telegram", async (req: Request, res: Response) => {
const secret = req.headers["x-telegram-bot-api-secret-token"] as string;
if (!verifyTelegramSecret(secret, req.body)) {
return res.status(401).json({ ok: false });
}
const msg = normalizeTelegramUpdate(req.body);
if (!msg) return res.json({ ok: true });
try {
const user = await findOrCreateUserFromBot("telegram", msg);
await handleCommand("telegram", msg, user);
} catch (err) {
console.error("[MedSchedule Bot] Telegram handler error:", err);
}
res.json({ ok: true });
});
app.post("/api/med/bot/webhook/max", async (req: Request, res: Response) => {
const secret = req.headers["x-max-bot-api-secret-token"] as string;
if (!verifyMaxSecret(secret, req.body)) {
return res.status(401).json({ ok: false });
}
const msg = normalizeMaxUpdate(req.body);
if (!msg) return res.json({ ok: true });
try {
const user = await findOrCreateUserFromBot("max", msg);
await handleCommand("max", msg, user);
} catch (err) {
console.error("[MedSchedule Bot] MAX handler error:", err);
}
res.json({ ok: true });
});
}