Архив: выбор чата в личке, отрицательные номера, стирание всего архива

- /archive в личке с ботом показывает список чатов с архивом (название и количество), дальше — листание и удаление
- Номера сообщений могут быть отрицательными (история группы до перехода в супергруппу) — кнопки и /archive_del их понимают
- /archive_del all — стереть весь архив чата с подтверждением; в личке первым аргументом ID чата
- В callback_data кнопок передаётся ID чата, поэтому архив группы можно чистить из лички

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-25 15:11:19 +02:00
parent 622cb5706a
commit f5d1ea6be2
5 changed files with 161 additions and 52 deletions

View File

@@ -29,8 +29,8 @@ export const helpText = (username: string) => `Я отвечаю только а
• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку
• /clear — сбросить контекст диалога (архив не трогаю)
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
• /archive — архив чата с кнопками удаления, листать кнопкой «Раньше»
• /archive_del 120-130 — удалить пачку сообщений из архива по номерам
• /archive — архив чата с кнопками удаления (в личке — выбор чата), листать кнопкой «Раньше»
• /archive_del 120-130 — удалить пачку по номерам, /archive_del all — весь архив (в личке сначала ID чата)
Личности:
• /personas — список и выбор кнопками

View File

@@ -1,4 +1,4 @@
import { Composer, InlineKeyboard } from "grammy";
import { Composer, InlineKeyboard, type Context } from "grammy";
import { formatUtc } from "../../libs/time.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
@@ -8,21 +8,22 @@ const PREVIEW_LENGTH = 150;
const BUTTONS_PER_ROW = 5;
/** Страница «самые свежие» — в callback_data кодируем нулём. */
const LATEST = 0;
/** Страница «самые свежие» в callback_data. Номер сообщения так не выглядит: номера бывают и отрицательными. */
const LATEST = "L";
const DEL_USAGE =
"Формат: /archive_del 120-130 135 — номера сообщений из /archive (можно диапазоны через дефис).";
/** Номер сообщения или ID чата: у импортированной истории старых групп и у групп они отрицательные. */
const NUM = "(-?\\d+)";
const preview = (text: string): string =>
text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}…` : text;
/** «120-130 135» → [{120..130}, {135..135}]; undefined — если что-то не разобралось. */
const parseRanges = (args: string): { from: number; to: number }[] | undefined => {
const tokens = args.split(/[\s,]+/).filter(Boolean);
const chunk = <T>(items: T[], size: number): T[][] =>
items.length === 0 ? [] : [items.slice(0, size), ...chunk(items.slice(size), size)];
/** «120-130 135 -999307862» → диапазоны; undefined — если что-то не разобралось. */
const parseRanges = (tokens: string[]): { from: number; to: number }[] | undefined => {
const ranges = tokens.map((token) => {
const match = token.match(/^(\d+)(?:-(\d+))?$/);
const match = token.match(/^(-?\d+)(?:-(-?\d+))?$/);
if (!match) return undefined;
@@ -35,22 +36,53 @@ const parseRanges = (args: string): { from: number; to: number }[] | undefined =
return tokens.length > 0 && ranges.every((range) => range !== undefined) ? ranges : undefined;
};
const chunk = <T>(items: T[], size: number): T[][] =>
items.length === 0 ? [] : [items.slice(0, size), ...chunk(items.slice(size), size)];
const isPrivate = (ctx: Context): boolean => ctx.chat?.type === "private";
/** Просмотр и удаление сообщений архива чата. Подключать только за фильтром администратора. */
/** Как называть чат в списке: название группы или имя в личке; если Telegram не ответил — ID. */
const chatTitle = async (ctx: Context, chatId: number): Promise<string> => {
const chat = await ctx.api.getChat(chatId).catch(() => undefined);
if (!chat) return String(chatId);
return "title" in chat && chat.title ? chat.title : `личка ${chat.first_name ?? chatId}`;
};
/** Просмотр и удаление сообщений архива. В группе — её архив, в личке с ботом — любой чат на выбор. Только для админов. */
export const createArchiveHandlers = (chatHistoryService: ChatHistoryService) => {
const composer = new Composer();
/** Страница архива: сообщения до `before` (или самые свежие) с кнопками удаления и листания. */
const renderPage = (chatId: number, before: number) => {
const messages = chatHistoryService.page(chatId, PAGE_SIZE, before === LATEST ? undefined : before);
/** Команда удаления пачки для заголовка: в личке к ней нужен ID чата. */
const delCommand = (chatId: number, inPrivate: boolean) => `/archive_del ${inPrivate ? `${chatId} ` : ""}`;
const renderChats = async (ctx: Context) => {
const chats = chatHistoryService.chats();
if (chats.length === 0) return { text: "Архив пуст — бот ещё ничего не сохранил.", reply_markup: new InlineKeyboard() };
const titles = await Promise.all(chats.map((chat) => chatTitle(ctx, chat.chatId)));
const keyboard = chats.reduce(
(kb, chat, i) => kb.text(`${titles[i]} — ${chat.count}`, `arch:chat:${chat.chatId}`).row(),
new InlineKeyboard(),
);
return { text: "Какой архив открыть?", reply_markup: keyboard };
};
/** Страница архива чата: сообщения до `before` (или самые свежие) с кнопками удаления и листания. */
const renderPage = (chatId: number, before: string, inPrivate: boolean) => {
const messages = chatHistoryService.page(chatId, PAGE_SIZE, before === LATEST ? undefined : Number(before));
const { count } = chatHistoryService.stats(chatId);
const backToChats = inPrivate ? [InlineKeyboard.text("📂 Чаты", "arch:list")] : [];
if (messages.length === 0) {
const text = before === LATEST ? "Архив этого чата пуст." : "Раньше сообщений нет.";
return { text, reply_markup: new InlineKeyboard().text("⏭ К последним", `arch:page:${LATEST}`) };
return {
text,
reply_markup: InlineKeyboard.from([[InlineKeyboard.text("⏭ К последним", `arch:page:${chatId}:${LATEST}`), ...backToChats]]),
};
}
const lines = messages.map((m) => `#${m.messageId} [${formatUtc(m.date)}] ${m.author}: ${preview(m.text)}`);
@@ -59,61 +91,112 @@ export const createArchiveHandlers = (chatHistoryService: ChatHistoryService) =>
const hasOlder = chatHistoryService.page(chatId, 1, oldest).length > 0;
const deleteRows = chunk(messages, BUTTONS_PER_ROW).map((row) =>
row.map((m) => InlineKeyboard.text(`🗑 ${m.messageId}`, `arch:del:${m.messageId}:${before}`)),
row.map((m) => InlineKeyboard.text(`🗑 ${m.messageId}`, `arch:del:${chatId}:${m.messageId}:${before}`)),
);
const navRow = [
...(hasOlder ? [InlineKeyboard.text("⬅️ Раньше", `arch:page:${oldest}`)] : []),
...(before !== LATEST ? [InlineKeyboard.text("⏭ К последним", `arch:page:${LATEST}`)] : []),
...(hasOlder ? [InlineKeyboard.text("⬅️ Раньше", `arch:page:${chatId}:${oldest}`)] : []),
...(before !== LATEST ? [InlineKeyboard.text("⏭ К последним", `arch:page:${chatId}:${LATEST}`)] : []),
...backToChats,
];
const text = [`Архив чата: ${count} сообщений. Удалить пачку: /archive_del 120-130`, "", ...lines].join("\n");
const cmd = delCommand(chatId, inPrivate);
const text = [
`Архив чата: ${count} сообщений.`,
`Удалить пачку: ${cmd}120-130 · весь архив: ${cmd}all`,
"",
...lines,
].join("\n");
return { text, reply_markup: InlineKeyboard.from([...deleteRows, ...(navRow.length > 0 ? [navRow] : [])]) };
};
composer.command("archive", async (ctx) => {
const { text, reply_markup } = renderPage(ctx.chat.id, LATEST);
const edit = async (ctx: Context, view: { text: string; reply_markup: InlineKeyboard }) =>
ctx.editMessageText(view.text, { reply_markup: view.reply_markup }).catch(() => {});
await ctx.reply(text, { reply_markup });
composer.command("archive", async (ctx) => {
const view = isPrivate(ctx) ? await renderChats(ctx) : renderPage(ctx.chat.id, LATEST, false);
await ctx.reply(view.text, { reply_markup: view.reply_markup });
});
composer.command("archive_del", async (ctx) => {
const ranges = parseRanges(ctx.match);
const tokens = ctx.match.split(/[\s,]+/).filter(Boolean);
const inPrivate = isPrivate(ctx);
if (!ranges) {
await ctx.reply(DEL_USAGE);
const [chatToken, ...rest] = inPrivate ? tokens : [String(ctx.chat.id), ...tokens];
const chatId = Number(chatToken);
const usage =
`Формат: ${inPrivate ? "/archive_del <ID чата> 120-130 135" : "/archive_del 120-130 135"} — номера из /archive, ` +
`можно диапазоны через дефис; весь архив: ${inPrivate ? "/archive_del <ID чата> all" : "/archive_del all"}.`;
if (!Number.isSafeInteger(chatId) || rest.length === 0) {
await ctx.reply(usage);
return;
}
const removed = chatHistoryService.remove(ctx.chat.id, ranges);
if (rest.length === 1 && rest[0]!.toLowerCase() === "all") {
const { count } = chatHistoryService.stats(chatId);
const keyboard = new InlineKeyboard()
.text(`🗑 Да, стереть все ${count}`, `arch:wipe:${chatId}`)
.text("Отмена", "arch:wipe_no");
await ctx.reply(`Стереть весь архив чата ${chatId} (${count} сообщений)? Вернуть можно только повторным /import.`, {
reply_markup: keyboard,
});
return;
}
const ranges = parseRanges(rest);
if (!ranges) {
await ctx.reply(usage);
return;
}
const removed = chatHistoryService.remove(chatId, ranges);
await ctx.reply(`Удалил из архива ${removed} сообщений. Посмотреть, что осталось: /archive`);
});
composer.callbackQuery(/^arch:del:(\d+):(\d+)$/, async (ctx) => {
const chatId = ctx.chat?.id;
if (chatId === undefined) return ctx.answerCallbackQuery();
const messageId = Number(ctx.match[1]);
const removed = chatHistoryService.remove(chatId, [{ from: messageId, to: messageId }]);
const { text, reply_markup } = renderPage(chatId, Number(ctx.match[2]));
await ctx.answerCallbackQuery({ text: removed > 0 ? `Удалено #${messageId}` : "Уже удалено" });
await ctx.editMessageText(text, { reply_markup }).catch(() => {});
composer.callbackQuery("arch:list", async (ctx) => {
await ctx.answerCallbackQuery();
await edit(ctx, await renderChats(ctx));
});
composer.callbackQuery(/^arch:page:(\d+)$/, async (ctx) => {
const chatId = ctx.chat?.id;
if (chatId === undefined) return ctx.answerCallbackQuery();
const { text, reply_markup } = renderPage(chatId, Number(ctx.match[1]));
composer.callbackQuery(new RegExp(`^arch:chat:${NUM}$`), async (ctx) => {
await ctx.answerCallbackQuery();
await ctx.editMessageText(text, { reply_markup }).catch(() => {});
await edit(ctx, renderPage(Number(ctx.match[1]), LATEST, isPrivate(ctx)));
});
composer.callbackQuery(new RegExp(`^arch:page:${NUM}:(-?\\d+|${LATEST})$`), async (ctx) => {
await ctx.answerCallbackQuery();
await edit(ctx, renderPage(Number(ctx.match[1]), ctx.match[2]!, isPrivate(ctx)));
});
composer.callbackQuery(new RegExp(`^arch:del:${NUM}:${NUM}:(-?\\d+|${LATEST})$`), async (ctx) => {
const chatId = Number(ctx.match[1]);
const messageId = Number(ctx.match[2]);
const removed = chatHistoryService.remove(chatId, [{ from: messageId, to: messageId }]);
await ctx.answerCallbackQuery({ text: removed > 0 ? `Удалено #${messageId}` : "Уже удалено" });
await edit(ctx, renderPage(chatId, ctx.match[3]!, isPrivate(ctx)));
});
composer.callbackQuery(new RegExp(`^arch:wipe:${NUM}$`), async (ctx) => {
const removed = chatHistoryService.removeAll(Number(ctx.match[1]));
await ctx.answerCallbackQuery({ text: "Архив стёрт" });
await ctx.editMessageText(`Готово: стёр весь архив чата (${removed} сообщений).`).catch(() => {});
});
composer.callbackQuery("arch:wipe_no", async (ctx) => {
await ctx.answerCallbackQuery({ text: "Отменено" });
await ctx.editMessageText("Стирание архива отменено.").catch(() => {});
});
return composer;

View File

@@ -48,6 +48,8 @@ export const createMessageRepository = (db: Db) => {
"SELECT * FROM messages WHERE chat_id = ? AND message_id < ? ORDER BY message_id DESC LIMIT ?",
),
deleteRange: db.prepare("DELETE FROM messages WHERE chat_id = ? AND message_id BETWEEN ? AND ?"),
deleteByChat: db.prepare("DELETE FROM messages WHERE chat_id = ?"),
countsByChat: db.prepare("SELECT chat_id AS chatId, COUNT(*) AS count FROM messages GROUP BY chat_id ORDER BY count DESC"),
};
const toChronological = (rows: unknown[]): ChatMessage[] => (rows as MessageRow[]).reverse().map(toMessage);
@@ -96,7 +98,25 @@ export const createMessageRepository = (db: Db) => {
const deleteRange = (chatId: number, from: number, to: number): number =>
statements.deleteRange.run(chatId, from, to).changes;
return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat, pageBefore, deleteRange };
/** Удаляет весь архив чата; возвращает, сколько сообщений удалено. */
const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes;
/** Чаты, у которых есть архив, и сколько в каждом сообщений — самые большие первыми. */
const countsByChat = (): { chatId: number; count: number }[] =>
statements.countsByChat.all() as { chatId: number; count: number }[];
return {
upsertAndPrune,
insertManyAndPrune,
latest,
latestByUser,
findById,
statsByChat,
pageBefore,
deleteRange,
deleteByChat,
countsByChat,
};
};
export type MessageRepository = ReturnType<typeof createMessageRepository>;

View File

@@ -29,7 +29,13 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch
const remove = (chatId: number, ranges: { from: number; to: number }[]): number =>
ranges.reduce((sum, { from, to }) => sum + messageRepository.deleteRange(chatId, from, to), 0);
return { record, latest, latestByUser, find, stats, page, remove };
/** Стереть весь архив чата. */
const removeAll = (chatId: number): number => messageRepository.deleteByChat(chatId);
/** Чаты с архивом — для выбора в личке с ботом. */
const chats = (): { chatId: number; count: number }[] => messageRepository.countsByChat();
return { record, latest, latestByUser, find, stats, page, remove, removeAll, chats };
};
export type ChatHistoryService = ReturnType<typeof createChatHistoryService>;