Команды для просмотра и удаления архива чата
- /archive: последние 10 сообщений архива с кнопками 🗑 (удалить) и «⬅️ Раньше» / «⏭ К последним» - /archive_del 120-130 135: удалить пачку по номерам, с диапазонами - Служебные команды боту больше не записываются в архив (/ask остаётся — в нём сам вопрос) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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` сбрасывает только контекст.
|
- *Контекст диалога* — вопросы к боту и его ответы в этом чате, хранит [Mastra Memory](https://mastra.ai/docs/memory/message-history) (`memory.db` рядом с `DB_PATH`). Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». Самые старые реплики Mastra срезает, когда промпт не влезает в `CONTEXT_TOKEN_LIMIT`; результаты инструментов из прошлых ответов (`ToolCallFilter`) и фото в историю не попадают. `/clear` сбрасывает только контекст.
|
||||||
- *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает.
|
- *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает.
|
||||||
|
- `/archive` — архив чата с кнопками 🗑 для удаления сообщений и «⬅️ Раньше» для листания; `/archive_del 120-130 135` — удалить пачку по номерам. Служебные команды боту в архив не пишутся.
|
||||||
- `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже).
|
- `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже).
|
||||||
- **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D).
|
- **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D).
|
||||||
- `/personas` — список и выбор кнопками
|
- `/personas` — список и выбор кнопками
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const BOT_COMMANDS = [
|
|||||||
{ command: "context", description: "Что бот помнит: контекст диалога и архив чата" },
|
{ command: "context", description: "Что бот помнит: контекст диалога и архив чата" },
|
||||||
{ command: "clear", description: "Сбросить контекст диалога (архив остаётся)" },
|
{ command: "clear", description: "Сбросить контекст диалога (архив остаётся)" },
|
||||||
{ command: "import", description: "Загрузить старую переписку из экспорта Telegram" },
|
{ command: "import", description: "Загрузить старую переписку из экспорта Telegram" },
|
||||||
|
{ command: "archive", description: "Архив чата: просмотр и удаление сообщений" },
|
||||||
|
{ command: "archive_del", description: "Удалить из архива: /archive_del 120-130" },
|
||||||
{ command: "help", description: "Справка" },
|
{ command: "help", description: "Справка" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@ export const helpText = (username: string) => `Я отвечаю только а
|
|||||||
• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку
|
• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку
|
||||||
• /clear — сбросить контекст диалога (архив не трогаю)
|
• /clear — сбросить контекст диалога (архив не трогаю)
|
||||||
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
|
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
|
||||||
|
• /archive — архив чата с кнопками удаления, листать кнопкой «Раньше»
|
||||||
|
• /archive_del 120-130 — удалить пачку сообщений из архива по номерам
|
||||||
|
|
||||||
Личности:
|
Личности:
|
||||||
• /personas — список и выбор кнопками
|
• /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 { ChatMemberService } from "../services/chat-member-service.js";
|
||||||
import type { HistoryImportService } from "../services/history-import-service.js";
|
import type { HistoryImportService } from "../services/history-import-service.js";
|
||||||
import type { PersonaService } from "../services/persona-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 { createContextHandlers } from "./handlers/context-handlers.js";
|
||||||
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
|
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
|
||||||
import { createHistoryHandlers } from "./handlers/history-handlers.js";
|
import { createHistoryHandlers } from "./handlers/history-handlers.js";
|
||||||
@@ -44,6 +45,7 @@ export const createBot = ({
|
|||||||
admin.use(createPersonaHandlers(personaService));
|
admin.use(createPersonaHandlers(personaService));
|
||||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, assistantService }));
|
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, assistantService }));
|
||||||
admin.use(createImportHandlers(historyImportService));
|
admin.use(createImportHandlers(historyImportService));
|
||||||
|
admin.use(createArchiveHandlers(chatHistoryService));
|
||||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||||
|
|
||||||
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
|
// Иначе у не-админа кнопка «крутится», пока 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 { 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 { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||||
import { createMessageRecorder } from "../reply.js";
|
import { createMessageRecorder } from "../reply.js";
|
||||||
@@ -19,7 +19,10 @@ export const createHistoryHandlers = ({ chatHistoryService, chatMemberService }:
|
|||||||
const msg = ctx.msg;
|
const msg = ctx.msg;
|
||||||
const author = ctx.from && !ctx.from.is_bot ? [toMemberProfile(ctx.from)] : [];
|
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));
|
[...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 = ?"),
|
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 = ?"),
|
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);
|
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 } =>
|
const statsByChat = (chatId: number): { count: number; oldest: number | null } =>
|
||||||
statements.statsByChat.get(chatId) as { 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>;
|
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 });
|
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>;
|
export type ChatHistoryService = ReturnType<typeof createChatHistoryService>;
|
||||||
|
|||||||
Reference in New Issue
Block a user