Команды для просмотра и удаления архива чата
- /archive: последние 10 сообщений архива с кнопками 🗑 (удалить) и «⬅️ Раньше» / «⏭ К последним» - /archive_del 120-130 135: удалить пачку по номерам, с диапазонами - Служебные команды боту больше не записываются в архив (/ask остаётся — в нём сам вопрос) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 — список и выбор кнопками
|
||||
|
||||
@@ -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 не отвалится по таймауту.
|
||||
|
||||
120
src/bot/handlers/archive-handlers.ts
Normal file
120
src/bot/handlers/archive-handlers.ts
Normal 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;
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
// Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user