diff --git a/README.md b/README.md index a8725e9..a257a81 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Telegram-бот для групповых чатов с AI-личностями. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. - **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/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 `) с подтверждением. - *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает. - `/archive` — архив чата с кнопками 🗑 для удаления сообщений и «⬅️ Раньше» для листания. В группе — её архив, в личке с ботом — список всех чатов с архивом на выбор. `/archive_del 120-130 135` — удалить пачку по номерам (в личке первым аргументом ID чата, он есть в заголовке списка), `/archive_del all` — стереть весь архив с подтверждением. Номера бывают отрицательными — так Telegram нумерует историю группы до перехода в супергруппу. Служебные команды боту в архив не пишутся. - `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже). diff --git a/src/bot/chat-title.ts b/src/bot/chat-title.ts new file mode 100644 index 0000000..e5eed60 --- /dev/null +++ b/src/bot/chat-title.ts @@ -0,0 +1,10 @@ +import type { Context } from "grammy"; + +/** Как называть чат в списках: название группы или имя в личке; если Telegram не ответил — ID. */ +export const chatTitle = async (ctx: Context, chatId: number): Promise => { + 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}`; +}; diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 7843199..5b45efd 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -27,7 +27,7 @@ export const helpText = (username: string) => `Я отвечаю только а • Помню последние вопросы ко мне и свои ответы — можно уточнять «а подробнее?». • Всю переписку чата храню отдельно, в архиве, и читаю её, когда нужно. • /context — что сейчас в контексте и в архиве, вижу ли я всю переписку -• /clear — сбросить контекст диалога (архив не трогаю) +• /clear — сбросить контекст диалога (архив не трогаю); в личке — выбор чата • /import — загрузить старую переписку из экспорта Telegram Desktop (JSON) • /archive — архив чата с кнопками удаления (в личке — выбор чата), листать кнопкой «Раньше» • /archive_del 120-130 — удалить пачку по номерам, /archive_del all — весь архив (в личке сначала ID чата) diff --git a/src/bot/handlers/archive-handlers.ts b/src/bot/handlers/archive-handlers.ts index 0a85a47..2a16748 100644 --- a/src/bot/handlers/archive-handlers.ts +++ b/src/bot/handlers/archive-handlers.ts @@ -1,6 +1,7 @@ import { Composer, InlineKeyboard, type Context } from "grammy"; import { formatUtc } from "../../libs/time.js"; import type { ChatHistoryService } from "../../services/chat-history-service.js"; +import { chatTitle } from "../chat-title.js"; 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"; -/** Как называть чат в списке: название группы или имя в личке; если Telegram не ответил — ID. */ -const chatTitle = async (ctx: Context, chatId: number): Promise => { - 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) => { const composer = new Composer(); diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts index ea7c1e4..480a973 100644 --- a/src/bot/handlers/context-handlers.ts +++ b/src/bot/handlers/context-handlers.ts @@ -1,8 +1,9 @@ -import { Composer, type Context } from "grammy"; +import { Composer, InlineKeyboard, type Context } from "grammy"; import { formatUtc } from "../../libs/time.js"; import type { ChatHistoryService } from "../../services/chat-history-service.js"; import type { ChatMemberService } from "../../services/chat-member-service.js"; import type { AssistantService } from "../../services/assistant/assistant-service.js"; +import { chatTitle } from "../chat-title.js"; interface ContextHandlersDeps { chatHistoryService: ChatHistoryService; @@ -48,10 +49,67 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService, a await ctx.reply(lines.join("\n")); }); - composer.command("clear", async (ctx) => { - await assistantService.clearDialog(ctx.chat.id); + const CLEARED = "Контекст диалога сброшен, начинаю с чистого листа. Архив переписки чата на месте."; - 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 ` с подтверждением. + 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; diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index cc7b5ec..d306dee 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -7,7 +7,7 @@ import type { Persona } from "../../types.js"; import type { ChatHistoryService } from "../chat-history-service.js"; import type { ChatMemberService } from "../chat-member-service.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 { createListChatMembersTool } from "./tools/list-chat-members-tool.js"; import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js"; @@ -117,8 +117,23 @@ export const createAssistantService = ({ /** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */ const clearDialog = (chatId: number): Promise => 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; diff --git a/src/services/assistant/memory.ts b/src/services/assistant/memory.ts index ba6d0fe..e67271c 100644 --- a/src/services/assistant/memory.ts +++ b/src/services/assistant/memory.ts @@ -28,3 +28,10 @@ export const createAssistantMemory = ({ path, maxTokens }: AssistantMemoryOption /** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */ 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; +};