diff --git a/CLAUDE.md b/CLAUDE.md index 89c5419..80be4b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ Telegram-бот на Node.js + TypeScript + grammY, AI через Mastra (OpenRo - `pnpm dev` — seed личностей + запуск с автоперезагрузкой (читает `.env`) - `pnpm seed` / `pnpm seed:dev` — синхронизировать личности из `constants/personas.ts` + `prompts/personas/*.md` с БД +- `pnpm import-history:dev ` — загрузить экспорт чата из Telegram Desktop с диска (в проде — `/import` боту) - `pnpm typecheck` — проверка типов - `pnpm build` / `pnpm start` — сборка в `dist/` и запуск - Node.js 24 (`.nvmrc`); Mastra требует Node >= 22.13 @@ -20,8 +21,8 @@ src/ config.ts — чтение и валидация env types.ts — доменные типы (Persona, ChatMessage, ChatMember) constants/ — список личностей (id, название) и прочие константы - scripts/ — отдельные точки входа (seed) - libs/ — инфраструктура без бизнес-логики (SQLite и миграции, чтение промптов, форматирование Telegram) + scripts/ — отдельные точки входа (seed, import-history) + libs/ — инфраструктура без бизнес-логики (SQLite и миграции, промпты, форматирование Telegram, парсер экспорта) repositories/ — только SQL и маппинг строк в доменные типы, по репозиторию на таблицу services/ — бизнес-логика; зависят от репозиториев, не знают про grammY assistant/ — AI на Mastra: агент, промпты, tools diff --git a/README.md b/README.md index df1db73..1fe0258 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,24 @@ Telegram-бот для групповых чатов с AI-личностями. - **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. - **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет. -- **Память чата**: `/context` — сколько сообщений и участников бот помнит и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются). +- **Память чата**: `/context` — сколько сообщений и участников бот помнит, ID чата и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются); `/import` — загрузить старую переписку из экспорта (см. ниже). - **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D). - `/personas` — список и выбор кнопками - `/persona ` — выбрать +## Импорт старой переписки + +Telegram не отдаёт ботам сообщения, написанные до их добавления, — их можно загрузить из экспорта. + +1. Telegram Desktop → меню чата → «Экспорт истории чата». Сними галочки с фото, видео и файлов, формат — **«Машиночитаемый JSON»**. +2. Пришли боту `result.json` с подписью `/import` — прямо в этот чат или боту в личку (в личке бот сам поймёт, какой это чат). Можно и ответить `/import` на уже отправленный файл. +3. Бот ответит, сколько сообщений и участников добавил. + +- Импорт идемпотентен: повторная загрузка ничего не дублирует, уже сохранённые ботом сообщения и ники не перезаписываются. +- Хранится не больше `HISTORY_LIMIT` последних сообщений на чат — для большой истории подними лимит. +- Ников в экспорте нет, только имена: участники из экспорта находятся по имени, ник подтянется, когда человек напишет при боте. +- Файл больше 20 МБ бот скачать не может (ограничение Bot API). Тогда положи его в volume (`/app/data`) и запусти в контейнере `node dist/scripts/import-history.js /app/data/result.json` (локально — `pnpm import-history:dev result.json`). + ## Промпты и личности - `prompts/system.md` — общие правила бота (формат ответа, инструменты). Читается при старте. diff --git a/package.json b/package.json index 0ff1ac9..89d47ad 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "seed": "node --env-file-if-exists=.env dist/scripts/seed.js", "start": "pnpm seed && node --env-file-if-exists=.env dist/index.js", "build": "tsc", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "import-history": "node --env-file-if-exists=.env dist/scripts/import-history.js", + "import-history:dev": "tsx --env-file-if-exists=.env src/scripts/import-history.ts" }, "packageManager": "pnpm@10.33.2", "pnpm": { diff --git a/src/bot/commands.ts b/src/bot/commands.ts index eb13f6c..803f43a 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -4,6 +4,7 @@ export const BOT_COMMANDS = [ { command: "persona", description: "Выбрать личность: /persona " }, { command: "context", description: "Что бот помнит о чате и видит ли переписку" }, { command: "clear", description: "Очистить сохранённую переписку чата" }, + { command: "import", description: "Загрузить старую переписку из экспорта Telegram" }, { command: "help", description: "Справка" }, ]; @@ -18,11 +19,12 @@ export const helpText = (username: string) => `Я отвечаю только а Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?». Фото тоже смотрю: пришли фото с подписью, где упомянут я, или ответь на фото командой /ask. Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы. -Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт. +Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт — но их можно загрузить из экспорта. Память: • /context — что я помню о чате и вижу ли всю переписку • /clear — забыть сохранённую переписку +• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON) Личности: • /personas — список и выбор кнопками diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 69e570c..90de65f 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -2,10 +2,12 @@ import { Bot, type Context } from "grammy"; import type { AssistantService } from "../services/assistant/assistant-service.js"; import type { ChatHistoryService } from "../services/chat-history-service.js"; import type { ChatMemberService } from "../services/chat-member-service.js"; +import type { HistoryImportService } from "../services/history-import-service.js"; import type { PersonaService } from "../services/persona-service.js"; import { createContextHandlers } from "./handlers/context-handlers.js"; import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js"; import { createHistoryHandlers } from "./handlers/history-handlers.js"; +import { createImportHandlers } from "./handlers/import-handlers.js"; import { createPersonaHandlers } from "./handlers/persona-handlers.js"; import { createQuestionHandlers } from "./handlers/question-handlers.js"; @@ -15,6 +17,7 @@ interface BotDeps { personaService: PersonaService; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; + historyImportService: HistoryImportService; assistantService: AssistantService; } @@ -24,6 +27,7 @@ export const createBot = ({ personaService, chatHistoryService, chatMemberService, + historyImportService, assistantService, }: BotDeps): Bot => { const bot = new Bot(token); @@ -39,6 +43,7 @@ export const createBot = ({ admin.use(createHelpHandlers()); admin.use(createPersonaHandlers(personaService)); admin.use(createContextHandlers({ chatHistoryService, chatMemberService })); + admin.use(createImportHandlers(historyImportService)); admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService })); // Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту. diff --git a/src/bot/files.ts b/src/bot/files.ts new file mode 100644 index 0000000..4ea2bbb --- /dev/null +++ b/src/bot/files.ts @@ -0,0 +1,18 @@ +import type { Api } from "grammy"; + +/** Bot API отдаёт ботам файлы размером не больше 20 МБ. */ +export const BOT_API_DOWNLOAD_LIMIT = 20 * 1024 * 1024; + +/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */ +export const downloadFile = async (api: Api, fileId: string, maxBytes = BOT_API_DOWNLOAD_LIMIT): Promise => { + const { file_path: filePath, file_size: fileSize } = await api.getFile(fileId); + + if (!filePath) throw new Error("Telegram не отдал путь к файлу"); + if (fileSize !== undefined && fileSize > maxBytes) throw new Error(`Файл слишком большой: ${fileSize} байт`); + + const response = await fetch(`https://api.telegram.org/file/bot${api.token}/${filePath}`); + + if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`); + + return new Uint8Array(await response.arrayBuffer()); +}; diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts index 8516c88..c6d31aa 100644 --- a/src/bot/handlers/context-handlers.ts +++ b/src/bot/handlers/context-handlers.ts @@ -41,7 +41,8 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService }: `Помню сообщений: ${count} из ${limit} возможных${since}.`, `Знаю участников: ${members}.`, await visibilityText(ctx), - "Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт.", + `ID чата: ${ctx.chat.id}`, + "Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.", "Очистить переписку: /clear", ]; diff --git a/src/bot/handlers/import-handlers.ts b/src/bot/handlers/import-handlers.ts new file mode 100644 index 0000000..55c7481 --- /dev/null +++ b/src/bot/handlers/import-handlers.ts @@ -0,0 +1,74 @@ +import { Composer, type Context } from "grammy"; +import type { Document } from "grammy/types"; +import { parseChatExport } from "../../libs/telegram-export.js"; +import { commandOf } from "../../libs/telegram-format.js"; +import type { HistoryImportService } from "../../services/history-import-service.js"; +import { BOT_API_DOWNLOAD_LIMIT, downloadFile } from "../files.js"; + +const HOW_TO = + "Как загрузить старую переписку:\n" + + "1. Telegram Desktop → меню чата → «Экспорт истории чата».\n" + + "2. Сними галочки с фото, видео и файлов, формат — «Машиночитаемый JSON».\n" + + "3. Пришли мне result.json с подписью /import — в этот чат или мне в личку."; + +const TOO_BIG = + "Файл больше 20 МБ — Telegram не даёт ботам скачивать такие. " + + "Экспортируй без медиа или загрузи на сервере: node dist/scripts/import-history.js /app/data/result.json"; + +const isJson = (document: Document): boolean => + document.mime_type === "application/json" || (document.file_name?.toLowerCase().endsWith(".json") ?? false); + +/** Импорт экспорта чата из Telegram Desktop. Подключать только за фильтром администратора. */ +export const createImportHandlers = (historyImportService: HistoryImportService) => { + const composer = new Composer(); + + const runImport = async (ctx: Context, document: Document | undefined) => { + if (!document || !isJson(document)) { + await ctx.reply(HOW_TO); + return; + } + + if (document.file_size !== undefined && document.file_size > BOT_API_DOWNLOAD_LIMIT) { + await ctx.reply(TOO_BIG); + return; + } + + try { + const bytes = await downloadFile(ctx.api, document.file_id); + const exported = parseChatExport(JSON.parse(new TextDecoder().decode(bytes))); + + // В группе принимаем только экспорт этой же группы; в личке — грузим туда, откуда экспорт. + if (ctx.chat!.type !== "private" && exported.chatId !== ctx.chat!.id) { + await ctx.reply(`Это экспорт другого чата («${exported.title}»). Пришли его в тот чат или мне в личку.`); + return; + } + + const result = historyImportService.importChat(exported); + + const lines = [ + `Загрузил «${exported.title}»: новых сообщений ${result.inserted} из ${result.total}, участников +${result.newMembers}.`, + `В истории сейчас ${result.stored} сообщений.`, + result.total > result.historyLimit + ? `Храню не больше ${result.historyLimit} последних — остальное не влезло. Нужно больше — подними HISTORY_LIMIT.` + : "", + ]; + + await ctx.reply(lines.filter(Boolean).join("\n")); + } catch (error) { + console.error("Ошибка импорта истории:", error); + await ctx.reply(`Не получилось загрузить: ${error instanceof Error ? error.message : "неизвестная ошибка"}`); + } + }; + + // Файл с подписью /import. grammY command() подписи не видит — разбираем через commandOf. + composer.on("message:document", async (ctx, next) => { + if (commandOf(ctx.msg, ctx.me.username)?.name !== "import") return next(); + + await runImport(ctx, ctx.msg.document); + }); + + // /import ответом на уже отправленный файл — или без файла, чтобы получить инструкцию. + composer.command("import", (ctx) => runImport(ctx, ctx.msg.reply_to_message?.document)); + + return composer; +}; diff --git a/src/bot/images.ts b/src/bot/images.ts index ec74ab1..2c23954 100644 --- a/src/bot/images.ts +++ b/src/bot/images.ts @@ -1,6 +1,7 @@ import type { Api } from "grammy"; import type { Message } from "grammy/types"; import type { ImageAttachment, ImageSource } from "../types.js"; +import { downloadFile } from "./files.js"; /** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */ const MAX_IMAGE_BYTES = 10 * 1024 * 1024; @@ -31,19 +32,11 @@ export const imageRefsOf = (msg: Message): ImageRef[] => { source: "replied" as const, file: imageFileOf(msg.reply_to_message) }, ].flatMap(({ source, file }) => (file ? [{ source, ...file }] : [])); -/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */ -const download = async (api: Api, ref: ImageRef): Promise => { - const { file_path: filePath, file_size: fileSize } = await api.getFile(ref.fileId); - - if (!filePath) throw new Error("Telegram не отдал путь к файлу"); - if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`); - - const response = await fetch(`https://api.telegram.org/file/bot${api.token}/${filePath}`); - - if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`); - - return { data: new Uint8Array(await response.arrayBuffer()), mimeType: ref.mimeType, source: ref.source }; -}; +const download = async (api: Api, ref: ImageRef): Promise => ({ + data: await downloadFile(api, ref.fileId, MAX_IMAGE_BYTES), + mimeType: ref.mimeType, + source: ref.source, +}); /** Скачивает картинки параллельно. Не скачалась — пропускаем, а не роняем ответ. */ export const downloadImages = async (api: Api, refs: ImageRef[]): Promise => { diff --git a/src/config.ts b/src/config.ts index 9083200..8aac254 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,13 +86,14 @@ const parseModel = (): ModelChoice => { return { model, imageInput }; }; -/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */ +/** Путь к SQLite; нужен боту и скриптам (`seed`, импорт), которым остальные переменные не нужны. */ export const DB_PATH = envValue("DB_PATH") ?? "data/bot.db"; +/** Сколько последних сообщений каждого чата хранить в базе; нужен боту и скрипту импорта. */ +export const HISTORY_LIMIT = positiveInt("HISTORY_LIMIT", 2000); + export const loadBotConfig = () => ({ botToken: required("BOT_TOKEN"), adminIds: parseAdminIds(required("ADMIN_IDS")), ai: parseModel(), - /** Сколько последних сообщений каждого чата хранить в базе. */ - historyLimit: positiveInt("HISTORY_LIMIT", 2000), }); diff --git a/src/index.ts b/src/index.ts index 2daa2af..557aab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { BOT_COMMANDS } from "./bot/commands.js"; import { createBot } from "./bot/create-bot.js"; -import { DB_PATH, loadBotConfig } from "./config.js"; +import { DB_PATH, HISTORY_LIMIT, loadBotConfig } from "./config.js"; import { createDb } from "./libs/db.js"; import { createChatMemberRepository } from "./repositories/chat-member-repository.js"; import { createChatSettingsRepository } from "./repositories/chat-settings-repository.js"; @@ -9,6 +9,7 @@ import { createPersonaRepository } from "./repositories/persona-repository.js"; import { createAssistantService } from "./services/assistant/assistant-service.js"; import { createChatHistoryService } from "./services/chat-history-service.js"; import { createChatMemberService } from "./services/chat-member-service.js"; +import { createHistoryImportService } from "./services/history-import-service.js"; import { createPersonaService } from "./services/persona-service.js"; const config = loadBotConfig(); @@ -22,12 +23,19 @@ const personaService = createPersonaService({ if (personaService.list().length === 0) throw new Error("В базе нет личностей — сначала запусти `pnpm seed`"); -const chatHistoryService = createChatHistoryService({ - messageRepository: createMessageRepository(db), - historyLimit: config.historyLimit, -}); +const messageRepository = createMessageRepository(db); -const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) }); +const chatMemberRepository = createChatMemberRepository(db); + +const chatHistoryService = createChatHistoryService({ messageRepository, historyLimit: HISTORY_LIMIT }); + +const chatMemberService = createChatMemberService({ chatMemberRepository }); + +const historyImportService = createHistoryImportService({ + messageRepository, + chatMemberRepository, + historyLimit: HISTORY_LIMIT, +}); const assistantService = createAssistantService({ model: config.ai.model, @@ -42,6 +50,7 @@ const bot = createBot({ personaService, chatHistoryService, chatMemberService, + historyImportService, assistantService, }); diff --git a/src/libs/telegram-export.ts b/src/libs/telegram-export.ts new file mode 100644 index 0000000..ee8adde --- /dev/null +++ b/src/libs/telegram-export.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import type { ChatMessage, MemberProfile } from "../types.js"; +import { MEDIA_LABELS, withLabel } from "./telegram-format.js"; + +/** Кусок текста в экспорте: строка или объект с форматированием (ссылка, жирный, упоминание…). */ +const textPartSchema = z.union([z.string(), z.object({ text: z.string() })]); + +const entitySchema = z.object({ type: z.string(), text: z.string(), user_id: z.number().optional() }); + +const exportMessageSchema = z.object({ + id: z.number(), + type: z.string(), + date: z.string(), + date_unixtime: z.string().optional(), + from: z.string().nullable().optional(), + from_id: z.string().optional(), + text: z.union([z.string(), z.array(textPartSchema)]).optional(), + text_entities: z.array(entitySchema).optional(), + reply_to_message_id: z.number().optional(), + photo: z.string().optional(), + file: z.string().optional(), + file_name: z.string().optional(), + media_type: z.string().optional(), + sticker_emoji: z.string().optional(), + poll: z.object({ question: z.string() }).optional(), +}); + +/** Экспорт одного чата из Telegram Desktop: «Экспорт истории чата» → формат JSON. */ +const chatExportSchema = z.object({ + name: z.string().nullable().optional(), + type: z.string(), + id: z.number(), + messages: z.array(exportMessageSchema), +}); + +type ExportMessage = z.infer; + +export interface ParsedChatExport { + title: string; + /** ID чата в формате Bot API (у супергрупп — `-100…`). */ + chatId: number; + messages: ChatMessage[]; + /** Авторы и люди, упомянутые по имени-ссылке. Ников в экспорте нет — только имена. */ + profiles: MemberProfile[]; +} + +/** В экспорте ID «голый»; Bot API добавляет к супергруппам и каналам `-100`, к обычным группам — минус. */ +const toBotApiChatId = (type: string, id: number): number => { + if (type.includes("supergroup") || type.includes("channel")) return -(1_000_000_000_000 + id); + if (type.includes("group")) return -id; + + return id; +}; + +/** `from_id` вида `user123456`; у сообщений от имени канала — `channel…`, их не считаем людьми. */ +const userIdOf = (fromId: string | undefined): number | null => { + const match = fromId?.match(/^user(\d+)$/); + + return match ? Number(match[1]) : null; +}; + +const plainText = (text: ExportMessage["text"]): string => { + if (text === undefined) return ""; + if (typeof text === "string") return text; + + return text.map((part) => (typeof part === "string" ? part : part.text)).join(""); +}; + +/** Та же разметка медиа, что у живых сообщений (`MEDIA_LABELS`), чтобы история читалась одинаково. */ +const exportMessageText = (msg: ExportMessage): string | undefined => { + const text = plainText(msg.text).trim(); + + if (msg.photo) return withLabel(MEDIA_LABELS.photo, text); + if (msg.media_type === "sticker") return withLabel(MEDIA_LABELS.sticker, msg.sticker_emoji ?? ""); + if (msg.media_type === "voice_message") return MEDIA_LABELS.voice; + if (msg.media_type === "video_message") return MEDIA_LABELS.videoNote; + if (msg.media_type === "video_file" || msg.media_type === "animation") return withLabel(MEDIA_LABELS.video, text); + if (msg.file) return withLabel(MEDIA_LABELS.file(msg.file_name), text); + if (msg.poll) return withLabel(MEDIA_LABELS.poll, msg.poll.question); + + return text || undefined; +}; + +/** Новые экспорты кладут `date_unixtime`; в старых только локальное время без пояса — берём как есть. */ +const unixTime = (msg: ExportMessage): number => + msg.date_unixtime ? Number(msg.date_unixtime) : Math.floor(Date.parse(msg.date) / 1000); + +const profileFromName = (userId: number, name: string): MemberProfile => ({ + userId, + username: null, + firstName: name, + lastName: null, +}); + +const toChatMessage = (chatId: number, msg: ExportMessage): ChatMessage | undefined => { + const text = exportMessageText(msg); + + if (msg.type !== "message" || !text) return undefined; + + return { + chatId, + messageId: msg.id, + userId: userIdOf(msg.from_id), + date: unixTime(msg), + author: msg.from ?? "Аноним", + text, + replyToMessageId: msg.reply_to_message_id ?? null, + }; +}; + +const profilesOf = (msg: ExportMessage): MemberProfile[] => { + const authorId = userIdOf(msg.from_id); + const author = authorId !== null && msg.from ? [profileFromName(authorId, msg.from)] : []; + + const mentioned = (msg.text_entities ?? []).flatMap((entity) => + entity.type === "mention_name" && entity.user_id !== undefined ? [profileFromName(entity.user_id, entity.text)] : [], + ); + + return [...author, ...mentioned]; +}; + +/** Последнее имя каждого человека — в экспорте оно могло меняться по ходу переписки. */ +const latestProfiles = (profiles: MemberProfile[]): MemberProfile[] => [ + ...new Map(profiles.map((profile) => [profile.userId, profile])).values(), +]; + +/** Разбирает `result.json`; бросает понятную ошибку, если это не экспорт одного чата. */ +export const parseChatExport = (json: unknown): ParsedChatExport => { + if (typeof json === "object" && json !== null && "chats" in json) { + throw new Error("Это экспорт всего аккаунта. Нужен экспорт одного чата: меню чата → «Экспорт истории чата»."); + } + + const parsed = chatExportSchema.safeParse(json); + + if (!parsed.success) throw new Error(`Не похоже на экспорт чата Telegram (JSON): ${parsed.error.issues[0]?.message}`); + + const { name, type, id, messages } = parsed.data; + const chatId = toBotApiChatId(type, id); + + return { + title: name ?? "без названия", + chatId, + messages: messages.flatMap((msg) => toChatMessage(chatId, msg) ?? []), + profiles: latestProfiles(messages.flatMap(profilesOf)), + }; +}; diff --git a/src/libs/telegram-format.ts b/src/libs/telegram-format.ts index e986f5a..ed5e34e 100644 --- a/src/libs/telegram-format.ts +++ b/src/libs/telegram-format.ts @@ -23,19 +23,30 @@ const messageBody = (msg: Message): { text: string; entities: MessageEntity[] } entities: msg.entities ?? msg.caption_entities ?? [], }); -const withLabel = (label: string, body: string): string => (body ? `${label} ${body}` : label); +/** Как медиа выглядят в сохранённой истории — одинаково для живых сообщений и импорта. */ +export const MEDIA_LABELS = { + photo: "[фото]", + video: "[видео]", + voice: "[голосовое сообщение]", + videoNote: "[видеосообщение]", + sticker: "[стикер]", + poll: "[опрос]", + file: (name: string | undefined) => `[файл ${name ?? "без имени"}]`, +}; + +export const withLabel = (label: string, body: string): string => (body ? `${label} ${body}` : label); /** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */ export const messageText = (msg: Message): string | undefined => { const { text } = messageBody(msg); - if (msg.photo) return withLabel("[фото]", text); - if (msg.video) return withLabel("[видео]", text); - if (msg.voice) return "[голосовое сообщение]"; - if (msg.video_note) return "[видеосообщение]"; - if (msg.sticker) return withLabel("[стикер]", msg.sticker.emoji ?? ""); - if (msg.document) return withLabel(`[файл ${msg.document.file_name ?? "без имени"}]`, text); - if (msg.poll) return withLabel("[опрос]", msg.poll.question); + if (msg.photo) return withLabel(MEDIA_LABELS.photo, text); + if (msg.video) return withLabel(MEDIA_LABELS.video, text); + if (msg.voice) return MEDIA_LABELS.voice; + if (msg.video_note) return MEDIA_LABELS.videoNote; + if (msg.sticker) return withLabel(MEDIA_LABELS.sticker, msg.sticker.emoji ?? ""); + if (msg.document) return withLabel(MEDIA_LABELS.file(msg.document.file_name), text); + if (msg.poll) return withLabel(MEDIA_LABELS.poll, msg.poll.question); return text || undefined; }; diff --git a/src/repositories/chat-member-repository.ts b/src/repositories/chat-member-repository.ts index f7ebbc8..790aa38 100644 --- a/src/repositories/chat-member-repository.ts +++ b/src/repositories/chat-member-repository.ts @@ -29,6 +29,10 @@ export const createChatMemberRepository = (db: Db) => { first_name = excluded.first_name, last_name = excluded.last_name `), + insertIfMissing: db.prepare(` + INSERT OR IGNORE INTO chat_members (chat_id, user_id, username, first_name, last_name) + VALUES (@chatId, @userId, @username, @firstName, @lastName) + `), // Активность считаем по сохранённой истории: после /clear и обрезки лимитом цифры остаются честными. listByChat: db.prepare(` SELECT m.user_id, m.username, m.first_name, m.last_name, @@ -47,10 +51,15 @@ export const createChatMemberRepository = (db: Db) => { statements.upsert.run({ chatId, ...profile }); }; + /** Массовая загрузка (импорт): известных участников не трогаем — у них уже есть ник и точное имя. */ + const insertManyIfMissing = db.transaction((chatId: number, profiles: MemberProfile[]): number => + profiles.reduce((sum, profile) => sum + statements.insertIfMissing.run({ chatId, ...profile }).changes, 0), + ); + const listByChat = (chatId: number): ChatMember[] => (statements.listByChat.all(chatId) as ChatMemberRow[]).map(toMember); - return { upsert, listByChat }; + return { upsert, insertManyIfMissing, listByChat }; }; export type ChatMemberRepository = ReturnType; diff --git a/src/repositories/message-repository.ts b/src/repositories/message-repository.ts index d7d5158..f30c2b0 100644 --- a/src/repositories/message-repository.ts +++ b/src/repositories/message-repository.ts @@ -28,6 +28,10 @@ export const createMessageRepository = (db: Db) => { VALUES (@chatId, @messageId, @userId, @date, @author, @text, @replyToMessageId) ON CONFLICT(chat_id, message_id) DO UPDATE SET text = excluded.text `), + insertIfMissing: db.prepare(` + INSERT OR IGNORE INTO messages (chat_id, message_id, user_id, date, author, text, reply_to_message_id) + VALUES (@chatId, @messageId, @userId, @date, @author, @text, @replyToMessageId) + `), pruneChat: db.prepare(` DELETE FROM messages WHERE chat_id = ? AND message_id <= ( SELECT message_id FROM messages WHERE chat_id = ? @@ -51,6 +55,18 @@ export const createMessageRepository = (db: Db) => { statements.pruneChat.run(message.chatId, message.chatId, keep); }); + /** + * Массовая загрузка (импорт): уже сохранённые сообщения не трогаем — живые данные точнее экспорта. + * Возвращает, сколько сообщений добавлено; затем историю чата обрезаем до `keep` последних. + */ + const insertManyAndPrune = db.transaction((chatId: number, messages: ChatMessage[], keep: number): number => { + const inserted = messages.reduce((sum, message) => sum + statements.insertIfMissing.run(message).changes, 0); + + statements.pruneChat.run(chatId, chatId, keep); + + return inserted; + }); + /** Последние `limit` сообщений чата в хронологическом порядке. */ const latest = (chatId: number, limit: number): ChatMessage[] => toChronological(statements.latest.all(chatId, limit)); @@ -72,7 +88,7 @@ export const createMessageRepository = (db: Db) => { /** Удаляет всю сохранённую историю чата; возвращает, сколько сообщений удалено. */ const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes; - return { upsertAndPrune, latest, latestByUser, findById, statsByChat, deleteByChat }; + return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat, deleteByChat }; }; export type MessageRepository = ReturnType; diff --git a/src/scripts/import-history.ts b/src/scripts/import-history.ts new file mode 100644 index 0000000..7ebdc80 --- /dev/null +++ b/src/scripts/import-history.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { DB_PATH, HISTORY_LIMIT } from "../config.js"; +import { createDb } from "../libs/db.js"; +import { parseChatExport } from "../libs/telegram-export.js"; +import { createChatMemberRepository } from "../repositories/chat-member-repository.js"; +import { createMessageRepository } from "../repositories/message-repository.js"; +import { createHistoryImportService } from "../services/history-import-service.js"; + +/** + * Загрузка экспорта чата с диска — для файлов больше 20 МБ, которые бот не может скачать сам. + * Использование: node dist/scripts/import-history.js /app/data/result.json + */ +const [path] = process.argv.slice(2); + +if (!path) throw new Error("Укажи путь к result.json: node dist/scripts/import-history.js <путь>"); + +const exported = parseChatExport(JSON.parse(readFileSync(path, "utf8"))); + +const db = createDb(DB_PATH); + +const historyImportService = createHistoryImportService({ + messageRepository: createMessageRepository(db), + chatMemberRepository: createChatMemberRepository(db), + historyLimit: HISTORY_LIMIT, +}); + +const result = historyImportService.importChat(exported); + +db.close(); + +console.log(`«${exported.title}» (чат ${exported.chatId}): новых сообщений ${result.inserted} из ${result.total}.`); +console.log(`В истории ${result.stored} (лимит ${result.historyLimit}), новых участников ${result.newMembers}.`); diff --git a/src/services/history-import-service.ts b/src/services/history-import-service.ts new file mode 100644 index 0000000..5c9c5e1 --- /dev/null +++ b/src/services/history-import-service.ts @@ -0,0 +1,46 @@ +import type { ParsedChatExport } from "../libs/telegram-export.js"; +import type { ChatMemberRepository } from "../repositories/chat-member-repository.js"; +import type { MessageRepository } from "../repositories/message-repository.js"; + +interface HistoryImportServiceDeps { + messageRepository: MessageRepository; + chatMemberRepository: ChatMemberRepository; + /** Сколько последних сообщений каждого чата хранить — импорт тоже в него упирается. */ + historyLimit: number; +} + +export interface ImportResult { + /** Сообщений в экспорте (без служебных и пустых). */ + total: number; + /** Сколько новых сообщений добавлено. */ + inserted: number; + /** Сколько сообщений чата в истории после импорта и обрезки лимитом. */ + stored: number; + historyLimit: number; + /** Сколько новых участников узнал бот. */ + newMembers: number; +} + +/** Загружает экспорт чата из Telegram Desktop в историю бота. */ +export const createHistoryImportService = ({ + messageRepository, + chatMemberRepository, + historyLimit, +}: HistoryImportServiceDeps) => { + const importChat = (exported: ParsedChatExport): ImportResult => { + const newMembers = chatMemberRepository.insertManyIfMissing(exported.chatId, exported.profiles); + const inserted = messageRepository.insertManyAndPrune(exported.chatId, exported.messages, historyLimit); + + return { + total: exported.messages.length, + inserted, + stored: messageRepository.statsByChat(exported.chatId).count, + historyLimit, + newMembers, + }; + }; + + return { importChat }; +}; + +export type HistoryImportService = ReturnType;