489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
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 });
|
||
});
|
||
}
|