Импорт старой переписки из экспорта Telegram Desktop
- /import: прислать result.json с подписью /import в чат или боту в личку (или ответить на файл) - Парсер экспорта (zod): текст частями, упоминания по имени-ссылке, медиа, служебные сообщения - Импорт идемпотентен и не перезаписывает живые сообщения и ники - Экспорт другого чата в группе отклоняется; в личке чат определяется по экспорту - CLI import-history для файлов больше 20 МБ - /context показывает ID чата - Общие downloadFile и MEDIA_LABELS для живых сообщений, фото и импорта Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <result.json>` — загрузить экспорт чата из 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
|
||||
|
||||
15
README.md
15
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 <id>` — выбрать
|
||||
|
||||
## Импорт старой переписки
|
||||
|
||||
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` — общие правила бота (формат ответа, инструменты). Читается при старте.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const BOT_COMMANDS = [
|
||||
{ command: "persona", description: "Выбрать личность: /persona <id>" },
|
||||
{ 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 — список и выбор кнопками
|
||||
|
||||
@@ -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 не отвалится по таймауту.
|
||||
|
||||
18
src/bot/files.ts
Normal file
18
src/bot/files.ts
Normal file
@@ -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<Uint8Array> => {
|
||||
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());
|
||||
};
|
||||
@@ -41,7 +41,8 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService }:
|
||||
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
||||
`Знаю участников: ${members}.`,
|
||||
await visibilityText(ctx),
|
||||
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт.",
|
||||
`ID чата: ${ctx.chat.id}`,
|
||||
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.",
|
||||
"Очистить переписку: /clear",
|
||||
];
|
||||
|
||||
|
||||
74
src/bot/handlers/import-handlers.ts
Normal file
74
src/bot/handlers/import-handlers.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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<ImageAttachment> => {
|
||||
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<ImageAttachment> => ({
|
||||
data: await downloadFile(api, ref.fileId, MAX_IMAGE_BYTES),
|
||||
mimeType: ref.mimeType,
|
||||
source: ref.source,
|
||||
});
|
||||
|
||||
/** Скачивает картинки параллельно. Не скачалась — пропускаем, а не роняем ответ. */
|
||||
export const downloadImages = async (api: Api, refs: ImageRef[]): Promise<ImageAttachment[]> => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
21
src/index.ts
21
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,
|
||||
});
|
||||
|
||||
|
||||
146
src/libs/telegram-export.ts
Normal file
146
src/libs/telegram-export.ts
Normal file
@@ -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<typeof exportMessageSchema>;
|
||||
|
||||
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)),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<typeof createChatMemberRepository>;
|
||||
|
||||
@@ -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<typeof createMessageRepository>;
|
||||
|
||||
32
src/scripts/import-history.ts
Normal file
32
src/scripts/import-history.ts
Normal file
@@ -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}.`);
|
||||
46
src/services/history-import-service.ts
Normal file
46
src/services/history-import-service.ts
Normal file
@@ -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<typeof createHistoryImportService>;
|
||||
Reference in New Issue
Block a user