- /import: прислать result.json с подписью /import в чат или боту в личку (или ответить на файл) - Парсер экспорта (zod): текст частями, упоминания по имени-ссылке, медиа, служебные сообщения - Импорт идемпотентен и не перезаписывает живые сообщения и ники - Экспорт другого чата в группе отклоняется; в личке чат определяется по экспорту - CLI import-history для файлов больше 20 МБ - /context показывает ID чата - Общие downloadFile и MEDIA_LABELS для живых сообщений, фото и импорта Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
3.5 KiB
TypeScript
76 lines
3.5 KiB
TypeScript
import { Composer, InlineKeyboard, type Context } from "grammy";
|
||
import { formatUtc } from "../../libs/time.js";
|
||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||
|
||
const CLEAR_CONFIRM = "clear:yes";
|
||
|
||
const CLEAR_CANCEL = "clear:no";
|
||
|
||
interface ContextHandlersDeps {
|
||
chatHistoryService: ChatHistoryService;
|
||
chatMemberService: ChatMemberService;
|
||
}
|
||
|
||
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
||
const visibilityText = async (ctx: Context): Promise<string> => {
|
||
if (ctx.chat?.type === "private") return "Это личка — я вижу все сообщения.";
|
||
if (ctx.me.can_read_all_group_messages) return "Privacy mode выключен — я вижу все сообщения группы.";
|
||
|
||
const member = await ctx.getChatMember(ctx.me.id).catch(() => undefined);
|
||
|
||
if (member?.status === "administrator") return "Я админ группы — вижу все сообщения.";
|
||
|
||
return (
|
||
"⚠️ Privacy mode включён и я не админ — вижу только команды, упоминания и ответы мне.\n" +
|
||
"Отключи в @BotFather (/setprivacy → Disable) и добавь меня в группу заново, либо сделай админом."
|
||
);
|
||
};
|
||
|
||
/** Что бот помнит о чате и очистка этой памяти. Подключать только за фильтром администратора. */
|
||
export const createContextHandlers = ({ chatHistoryService, chatMemberService }: ContextHandlersDeps) => {
|
||
const composer = new Composer();
|
||
|
||
composer.command("context", async (ctx) => {
|
||
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
|
||
const members = chatMemberService.list(ctx.chat.id).length;
|
||
|
||
const since = oldest === null ? "" : `, с ${formatUtc(oldest)}`;
|
||
|
||
const lines = [
|
||
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
||
`Знаю участников: ${members}.`,
|
||
await visibilityText(ctx),
|
||
`ID чата: ${ctx.chat.id}`,
|
||
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.",
|
||
"Очистить переписку: /clear",
|
||
];
|
||
|
||
await ctx.reply(lines.join("\n"));
|
||
});
|
||
|
||
composer.command("clear", async (ctx) => {
|
||
const { count } = chatHistoryService.stats(ctx.chat.id);
|
||
|
||
const keyboard = new InlineKeyboard().text("🗑 Да, очистить", CLEAR_CONFIRM).text("Отмена", CLEAR_CANCEL);
|
||
|
||
await ctx.reply(`Забыть сохранённую переписку этого чата (${count} сообщений)? Имена участников останутся.`, {
|
||
reply_markup: keyboard,
|
||
});
|
||
});
|
||
|
||
composer.callbackQuery(CLEAR_CONFIRM, async (ctx) => {
|
||
const removed = ctx.chat ? chatHistoryService.clear(ctx.chat.id) : 0;
|
||
|
||
await ctx.answerCallbackQuery({ text: "Контекст очищен" });
|
||
await ctx.editMessageText(`Готово: забыл ${removed} сообщений. Начинаю с чистого листа.`).catch(() => {});
|
||
});
|
||
|
||
composer.callbackQuery(CLEAR_CANCEL, async (ctx) => {
|
||
await ctx.answerCallbackQuery({ text: "Отменено" });
|
||
await ctx.editMessageText("Очистка отменена.").catch(() => {});
|
||
});
|
||
|
||
return composer;
|
||
};
|