Импорт старой переписки из экспорта 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:
@@ -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[]> => {
|
||||
|
||||
Reference in New Issue
Block a user