- /import: прислать result.json с подписью /import в чат или боту в личку (или ответить на файл) - Парсер экспорта (zod): текст частями, упоминания по имени-ссылке, медиа, служебные сообщения - Импорт идемпотентен и не перезаписывает живые сообщения и ники - Экспорт другого чата в группе отклоняется; в личке чат определяется по экспорту - CLI import-history для файлов больше 20 МБ - /context показывает ID чата - Общие downloadFile и MEDIA_LABELS для живых сообщений, фото и импорта Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
3.8 KiB
TypeScript
75 lines
3.8 KiB
TypeScript
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;
|
||
};
|