/clear из лички: выбор чата кнопками с подтверждением
- /clear в личке с ботом показывает чаты с контекстом диалога (название и число реплик), дальше подтверждение - /clear <ID чата> в личке — сразу подтверждение для этого чата; в группе /clear работает как раньше - Общий хелпер chatTitle для /archive и /clear Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
10
src/bot/chat-title.ts
Normal 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}`;
|
||||||
|
};
|
||||||
@@ -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 чата)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user