/clear из лички: выбор чата кнопками с подтверждением

- /clear в личке с ботом показывает чаты с контекстом диалога (название и число реплик), дальше подтверждение
- /clear <ID чата> в личке — сразу подтверждение для этого чата; в группе /clear работает как раньше
- Общий хелпер chatTitle для /archive и /clear

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-25 15:17:18 +02:00
parent f5d1ea6be2
commit 08108f5e1f
7 changed files with 99 additions and 17 deletions

View File

@@ -10,7 +10,7 @@ Telegram-бот для групповых чатов с AI-личностями.
- **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени.
- **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет. - **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет.
- **Память — две части**: - **Память — две части**:
- *Контекст диалога* — вопросы к боту и его ответы в этом чате, хранит [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 <ID чата>`) с подтверждением.
- *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает. - *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает.
- `/archive` — архив чата с кнопками 🗑 для удаления сообщений и «⬅️ Раньше» для листания. В группе — её архив, в личке с ботом — список всех чатов с архивом на выбор. `/archive_del 120-130 135` — удалить пачку по номерам (в личке первым аргументом ID чата, он есть в заголовке списка), `/archive_del all` — стереть весь архив с подтверждением. Номера бывают отрицательными — так Telegram нумерует историю группы до перехода в супергруппу. Служебные команды боту в архив не пишутся. - `/archive` — архив чата с кнопками 🗑 для удаления сообщений и «⬅️ Раньше» для листания. В группе — её архив, в личке с ботом — список всех чатов с архивом на выбор. `/archive_del 120-130 135` — удалить пачку по номерам (в личке первым аргументом ID чата, он есть в заголовке списка), `/archive_del all` — стереть весь архив с подтверждением. Номера бывают отрицательными — так Telegram нумерует историю группы до перехода в супергруппу. Служебные команды боту в архив не пишутся.
- `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже). - `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже).

10
src/bot/chat-title.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Context } from "grammy";
/** Как называть чат в списках: название группы или имя в личке; если Telegram не ответил — ID. */
export 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}`;
};

View File

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

View File

@@ -1,6 +1,7 @@
import { Composer, InlineKeyboard, type Context } from "grammy"; import { Composer, InlineKeyboard, type Context } from "grammy";
import { formatUtc } from "../../libs/time.js"; import { formatUtc } from "../../libs/time.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js"; import type { ChatHistoryService } from "../../services/chat-history-service.js";
import { chatTitle } from "../chat-title.js";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -38,15 +39,6 @@ const parseRanges = (tokens: string[]): { from: number; to: number }[] | undefin
const isPrivate = (ctx: Context): boolean => ctx.chat?.type === "private"; 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) => { export const createArchiveHandlers = (chatHistoryService: ChatHistoryService) => {
const composer = new Composer(); const composer = new Composer();

View File

@@ -1,8 +1,9 @@
import { Composer, type Context } from "grammy"; import { Composer, InlineKeyboard, type Context } from "grammy";
import { formatUtc } from "../../libs/time.js"; import { formatUtc } from "../../libs/time.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 type { AssistantService } from "../../services/assistant/assistant-service.js"; import type { AssistantService } from "../../services/assistant/assistant-service.js";
import { chatTitle } from "../chat-title.js";
interface ContextHandlersDeps { interface ContextHandlersDeps {
chatHistoryService: ChatHistoryService; chatHistoryService: ChatHistoryService;
@@ -48,10 +49,67 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService, a
await ctx.reply(lines.join("\n")); await ctx.reply(lines.join("\n"));
}); });
composer.command("clear", async (ctx) => { const CLEARED = "Контекст диалога сброшен, начинаю с чистого листа. Архив переписки чата на месте.";
await assistantService.clearDialog(ctx.chat.id);
await ctx.reply("Контекст диалога сброшен, начинаю с чистого листа. Архив переписки чата на месте."); /** Подтверждение сброса контекста конкретного чата (из лички можно сбросить любой). */
const confirmView = async (ctx: Context, chatId: number) => {
const [title, messages] = await Promise.all([chatTitle(ctx, chatId), assistantService.dialogSize(chatId)]);
return {
text: `Сбросить контекст диалога «${title}» (реплик: ${messages})? Архив переписки не трогаю.`,
reply_markup: new InlineKeyboard().text("🧹 Сбросить", `ctx:clear:${chatId}`).text("Отмена", "ctx:no"),
};
};
/** Личка: чаты, где есть контекст диалога, — кнопками. */
const chatsView = async (ctx: Context) => {
const chats = await assistantService.dialogChats();
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.messages}`, `ctx:pick:${chat.chatId}`).row(),
new InlineKeyboard(),
);
return { text: "Чей контекст диалога сбросить?", reply_markup: keyboard };
};
// В группе — сразу её контекст; в личке — выбор чата или `/clear <ID чата>` с подтверждением.
composer.command("clear", async (ctx) => {
if (ctx.chat.type !== "private") {
await assistantService.clearDialog(ctx.chat.id);
await ctx.reply(CLEARED);
return;
}
const chatId = Number(ctx.match.trim());
const view = ctx.match.trim() && Number.isSafeInteger(chatId) ? await confirmView(ctx, chatId) : await chatsView(ctx);
await ctx.reply(view.text, { reply_markup: view.reply_markup });
});
composer.callbackQuery(/^ctx:pick:(-?\d+)$/, async (ctx) => {
const view = await confirmView(ctx, Number(ctx.match[1]));
await ctx.answerCallbackQuery();
await ctx.editMessageText(view.text, { reply_markup: view.reply_markup }).catch(() => {});
});
composer.callbackQuery(/^ctx:clear:(-?\d+)$/, async (ctx) => {
const chatId = Number(ctx.match[1]);
await assistantService.clearDialog(chatId);
await ctx.answerCallbackQuery({ text: "Контекст сброшен" });
await ctx.editMessageText(`«${await chatTitle(ctx, chatId)}»: ${CLEARED}`).catch(() => {});
});
composer.callbackQuery("ctx:no", async (ctx) => {
await ctx.answerCallbackQuery({ text: "Отменено" });
await ctx.editMessageText("Сброс контекста отменён.").catch(() => {});
}); });
return composer; return composer;

View File

@@ -7,7 +7,7 @@ import type { Persona } from "../../types.js";
import type { ChatHistoryService } from "../chat-history-service.js"; import type { ChatHistoryService } from "../chat-history-service.js";
import type { ChatMemberService } from "../chat-member-service.js"; import type { ChatMemberService } from "../chat-member-service.js";
import { stripPastReasoning } from "./history-compat.js"; import { stripPastReasoning } from "./history-compat.js";
import { chatThreadId } from "./memory.js"; import { chatIdOfThread, chatThreadId } from "./memory.js";
import { buildInstructions, buildUserText, type Question } from "./prompts.js"; import { buildInstructions, buildUserText, type Question } from "./prompts.js";
import { createListChatMembersTool } from "./tools/list-chat-members-tool.js"; import { createListChatMembersTool } from "./tools/list-chat-members-tool.js";
import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js"; import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js";
@@ -117,8 +117,23 @@ export const createAssistantService = ({
/** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */ /** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */
const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId)); const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId));
/** Чаты, где есть контекст диалога, и сколько в нём реплик — для выбора в личке с ботом. */
const dialogChats = async (): Promise<{ chatId: number; messages: number }[]> => {
const { threads } = await memory.listThreads({ perPage: false });
const chatIds = threads.flatMap((thread) => {
const chatId = chatIdOfThread(thread.id);
return chatId === undefined ? [] : [chatId];
});
const chats = await Promise.all(chatIds.map(async (chatId) => ({ chatId, messages: await dialogSize(chatId) })));
return chats.filter((chat) => chat.messages > 0).sort((a, b) => b.messages - a.messages);
};
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */ /** Боту стоит скачивать фото, только если модель умеет их смотреть. */
return { answer, dialogSize, clearDialog, canSeeImages: imageInput }; return { answer, dialogSize, clearDialog, dialogChats, canSeeImages: imageInput };
}; };
export type AssistantService = ReturnType<typeof createAssistantService>; export type AssistantService = ReturnType<typeof createAssistantService>;

View File

@@ -28,3 +28,10 @@ export const createAssistantMemory = ({ path, maxTokens }: AssistantMemoryOption
/** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */ /** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */
export const chatThreadId = (chatId: number): string => `chat:${chatId}`; export const chatThreadId = (chatId: number): string => `chat:${chatId}`;
/** Обратно из ID треда в ID чата; undefined — если тред создан не ботом. */
export const chatIdOfThread = (threadId: string): number | undefined => {
const match = threadId.match(/^chat:(-?\d+)$/);
return match ? Number(match[1]) : undefined;
};