Команды для просмотра и удаления архива чата

- /archive: последние 10 сообщений архива с кнопками 🗑 (удалить) и «⬅️ Раньше» / «⏭ К последним»
- /archive_del 120-130 135: удалить пачку по номерам, с диапазонами
- Служебные команды боту больше не записываются в архив (/ask остаётся — в нём сам вопрос)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-25 15:05:50 +02:00
parent fd2a219ae1
commit 622cb5706a
7 changed files with 154 additions and 4 deletions

View File

@@ -5,6 +5,8 @@ export const BOT_COMMANDS = [
{ command: "context", description: "Что бот помнит: контекст диалога и архив чата" },
{ command: "clear", description: "Сбросить контекст диалога (архив остаётся)" },
{ command: "import", description: "Загрузить старую переписку из экспорта Telegram" },
{ command: "archive", description: "Архив чата: просмотр и удаление сообщений" },
{ command: "archive_del", description: "Удалить из архива: /archive_del 120-130" },
{ command: "help", description: "Справка" },
];
@@ -27,6 +29,8 @@ export const helpText = (username: string) => `Я отвечаю только а
• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку
• /clear — сбросить контекст диалога (архив не трогаю)
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
• /archive — архив чата с кнопками удаления, листать кнопкой «Раньше»
• /archive_del 120-130 — удалить пачку сообщений из архива по номерам
Личности:
• /personas — список и выбор кнопками

View File

@@ -4,6 +4,7 @@ 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 { createArchiveHandlers } from "./handlers/archive-handlers.js";
import { createContextHandlers } from "./handlers/context-handlers.js";
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
import { createHistoryHandlers } from "./handlers/history-handlers.js";
@@ -44,6 +45,7 @@ export const createBot = ({
admin.use(createPersonaHandlers(personaService));
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, assistantService }));
admin.use(createImportHandlers(historyImportService));
admin.use(createArchiveHandlers(chatHistoryService));
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.

View File

@@ -0,0 +1,120 @@
import { Composer, InlineKeyboard } from "grammy";
import { formatUtc } from "../../libs/time.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
const PAGE_SIZE = 10;
const PREVIEW_LENGTH = 150;
const BUTTONS_PER_ROW = 5;
/** Страница «самые свежие» — в callback_data кодируем нулём. */
const LATEST = 0;
const DEL_USAGE =
"Формат: /archive_del 120-130 135 — номера сообщений из /archive (можно диапазоны через дефис).";
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 ranges = tokens.map((token) => {
const match = token.match(/^(\d+)(?:-(\d+))?$/);
if (!match) return undefined;
const a = Number(match[1]);
const b = Number(match[2] ?? match[1]);
return { from: Math.min(a, b), to: Math.max(a, b) };
});
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)];
/** Просмотр и удаление сообщений архива чата. Подключать только за фильтром администратора. */
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);
const { count } = chatHistoryService.stats(chatId);
if (messages.length === 0) {
const text = before === LATEST ? "Архив этого чата пуст." : "Раньше сообщений нет.";
return { text, reply_markup: new InlineKeyboard().text("⏭ К последним", `arch:page:${LATEST}`) };
}
const lines = messages.map((m) => `#${m.messageId} [${formatUtc(m.date)}] ${m.author}: ${preview(m.text)}`);
const oldest = messages[0]!.messageId;
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}`)),
);
const navRow = [
...(hasOlder ? [InlineKeyboard.text("⬅️ Раньше", `arch:page:${oldest}`)] : []),
...(before !== LATEST ? [InlineKeyboard.text("⏭ К последним", `arch:page:${LATEST}`)] : []),
];
const text = [`Архив чата: ${count} сообщений. Удалить пачку: /archive_del 120-130`, "", ...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);
await ctx.reply(text, { reply_markup });
});
composer.command("archive_del", async (ctx) => {
const ranges = parseRanges(ctx.match);
if (!ranges) {
await ctx.reply(DEL_USAGE);
return;
}
const removed = chatHistoryService.remove(ctx.chat.id, 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: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]));
await ctx.answerCallbackQuery();
await ctx.editMessageText(text, { reply_markup }).catch(() => {});
});
return composer;
};

View File

@@ -1,5 +1,5 @@
import { Composer } from "grammy";
import { mentionedProfiles, toMemberProfile } from "../../libs/telegram-format.js";
import { commandOf, mentionedProfiles, toMemberProfile } from "../../libs/telegram-format.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { ChatMemberService } from "../../services/chat-member-service.js";
import { createMessageRecorder } from "../reply.js";
@@ -19,7 +19,10 @@ export const createHistoryHandlers = ({ chatHistoryService, chatMemberService }:
const msg = ctx.msg;
const author = ctx.from && !ctx.from.is_bot ? [toMemberProfile(ctx.from)] : [];
recordMessage(msg);
// Служебные команды боту (/archive, /clear…) — не переписка; /ask оставляем: в нём сам вопрос.
const command = commandOf(msg, ctx.me.username);
if (!command || command.name === "ask") recordMessage(msg);
[...author, ...mentionedProfiles(msg)].forEach((profile) => chatMemberService.remember(msg.chat.id, profile));
// Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно.

View File

@@ -44,6 +44,10 @@ export const createMessageRepository = (db: Db) => {
),
findById: db.prepare("SELECT * FROM messages WHERE chat_id = ? AND message_id = ?"),
statsByChat: db.prepare("SELECT COUNT(*) AS count, MIN(date) AS oldest FROM messages WHERE chat_id = ?"),
pageBefore: db.prepare(
"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 ?"),
};
const toChronological = (rows: unknown[]): ChatMessage[] => (rows as MessageRow[]).reverse().map(toMessage);
@@ -84,7 +88,15 @@ export const createMessageRepository = (db: Db) => {
const statsByChat = (chatId: number): { count: number; oldest: number | null } =>
statements.statsByChat.get(chatId) as { count: number; oldest: number | null };
return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat };
/** Страница архива: до `limit` сообщений с номером меньше `beforeMessageId`, в хронологическом порядке. */
const pageBefore = (chatId: number, beforeMessageId: number, limit: number): ChatMessage[] =>
toChronological(statements.pageBefore.all(chatId, beforeMessageId, limit));
/** Удаляет сообщения чата с номерами от `from` до `to` включительно; возвращает, сколько удалено. */
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 };
};
export type MessageRepository = ReturnType<typeof createMessageRepository>;

View File

@@ -21,7 +21,15 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch
const stats = (chatId: number) => ({ ...messageRepository.statsByChat(chatId), limit: historyLimit });
return { record, latest, latestByUser, find, stats };
/** Страница архива для просмотра: сообщения до `beforeMessageId` (не включая); без него — самые последние. */
const page = (chatId: number, limit: number, beforeMessageId = Number.MAX_SAFE_INTEGER): ChatMessage[] =>
messageRepository.pageBefore(chatId, beforeMessageId, limit);
/** Удаляет из архива диапазоны номеров сообщений; возвращает, сколько удалено всего. */
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 };
};
export type ChatHistoryService = ReturnType<typeof createChatHistoryService>;