Импорт старой переписки из экспорта 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:
yunogasai
2026-09-24 20:18:43 +02:00
parent e812f1b1e2
commit 510221fd3c
17 changed files with 417 additions and 38 deletions

View File

@@ -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

View File

@@ -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` — общие правила бота (формат ответа, инструменты). Читается при старте.

View File

@@ -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": {

View File

@@ -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 — список и выбор кнопками

View File

@@ -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
View 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());
};

View File

@@ -41,7 +41,8 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService }:
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
`Знаю участников: ${members}.`,
await visibilityText(ctx),
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт.",
`ID чата: ${ctx.chat.id}`,
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.",
"Очистить переписку: /clear",
];

View 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;
};

View File

@@ -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[]> => {

View File

@@ -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),
});

View File

@@ -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
View 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)),
};
};

View File

@@ -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;
};

View File

@@ -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>;

View File

@@ -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>;

View 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}.`);

View 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>;