/clear из лички: выбор чата кнопками с подтверждением
- /clear в личке с ботом показывает чаты с контекстом диалога (название и число реплик), дальше подтверждение - /clear <ID чата> в личке — сразу подтверждение для этого чата; в группе /clear работает как раньше - Общий хелпер chatTitle для /archive и /clear Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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 — что сейчас в контексте и в архиве, вижу ли я всю переписку
|
||||
• /clear — сбросить контекст диалога (архив не трогаю)
|
||||
• /clear — сбросить контекст диалога (архив не трогаю); в личке — выбор чата
|
||||
• /import — загрузить старую переписку из экспорта Telegram Desktop (JSON)
|
||||
• /archive — архив чата с кнопками удаления (в личке — выбор чата), листать кнопкой «Раньше»
|
||||
• /archive_del 120-130 — удалить пачку по номерам, /archive_del all — весь архив (в личке сначала ID чата)
|
||||
|
||||
@@ -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<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) => {
|
||||
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 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 <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;
|
||||
|
||||
@@ -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<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>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user