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 { 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 { 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 }); }); }