diff --git a/README.md b/README.md index 1408bd2..1833915 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Telegram-бот для групповых чатов с AI-личностями. - **Память — две части**: - *Контекст диалога* — вопросы к боту и его ответы в этом чате, хранит [Mastra Memory](https://mastra.ai/docs/memory/message-history) (`memory.db` рядом с `DB_PATH`). Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». Самые старые реплики Mastra срезает, когда промпт не влезает в `CONTEXT_TOKEN_LIMIT`; результаты инструментов из прошлых ответов (`ToolCallFilter`) и фото в историю не попадают. `/clear` сбрасывает только контекст. - *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает. + - `/archive` — архив чата с кнопками 🗑 для удаления сообщений и «⬅️ Раньше» для листания; `/archive_del 120-130 135` — удалить пачку по номерам. Служебные команды боту в архив не пишутся. - `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже). - **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D). - `/personas` — список и выбор кнопками diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 2e7dccc..f6481cf 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -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 — список и выбор кнопками diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 1281fca..dbeebda 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -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 не отвалится по таймауту. diff --git a/src/bot/handlers/archive-handlers.ts b/src/bot/handlers/archive-handlers.ts new file mode 100644 index 0000000..0cc21e3 --- /dev/null +++ b/src/bot/handlers/archive-handlers.ts @@ -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 = (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; +}; diff --git a/src/bot/handlers/history-handlers.ts b/src/bot/handlers/history-handlers.ts index b2f6fa2..11d8343 100644 --- a/src/bot/handlers/history-handlers.ts +++ b/src/bot/handlers/history-handlers.ts @@ -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)); // Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно. diff --git a/src/repositories/message-repository.ts b/src/repositories/message-repository.ts index be2e524..f779f48 100644 --- a/src/repositories/message-repository.ts +++ b/src/repositories/message-repository.ts @@ -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; diff --git a/src/services/chat-history-service.ts b/src/services/chat-history-service.ts index 91abee0..f9a8d6d 100644 --- a/src/services/chat-history-service.ts +++ b/src/services/chat-history-service.ts @@ -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;