Фото в вопросах, Gemini 3.8 Flash по умолчанию, команды /context и /clear
- Бот смотрит фото: подпись с упоминанием, /ask в подписи или ответом на фото, фото в личке - Картинки скачиваются через Bot API и передаются модели байтами (ссылка с токеном наружу не уходит) - OpenRouter по умолчанию — openrouter/google/gemini-3.8-flash; AI_IMAGE_INPUT для явного выбора - Модель без зрения получает пометку, что фото есть, но она его не видит - /context: сколько бот помнит и видит ли всю переписку (privacy mode / админ) - /clear: очистка сохранённой переписки чата с подтверждением Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ export const BOT_COMMANDS = [
|
||||
{ command: "ask", description: "Задать вопрос боту" },
|
||||
{ command: "personas", description: "Список личностей и выбор" },
|
||||
{ command: "persona", description: "Выбрать личность: /persona <id>" },
|
||||
{ command: "context", description: "Что бот помнит о чате и видит ли переписку" },
|
||||
{ command: "clear", description: "Очистить сохранённую переписку чата" },
|
||||
{ command: "help", description: "Справка" },
|
||||
];
|
||||
|
||||
@@ -14,7 +16,13 @@ export const helpText = (username: string) => `Я отвечаю только а
|
||||
|
||||
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
|
||||
Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?».
|
||||
Фото тоже смотрю: пришли фото с подписью, где упомянут я, или ответь на фото командой /ask.
|
||||
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
|
||||
Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт.
|
||||
|
||||
Память:
|
||||
• /context — что я помню о чате и вижу ли всю переписку
|
||||
• /clear — забыть сохранённую переписку
|
||||
|
||||
Личности:
|
||||
• /personas — список и выбор кнопками
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AssistantService } from "../services/assistant/assistant-service.j
|
||||
import type { ChatHistoryService } from "../services/chat-history-service.js";
|
||||
import type { ChatMemberService } from "../services/chat-member-service.js";
|
||||
import type { PersonaService } from "../services/persona-service.js";
|
||||
import { createContextHandlers } from "./handlers/context-handlers.js";
|
||||
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
|
||||
import { createHistoryHandlers } from "./handlers/history-handlers.js";
|
||||
import { createPersonaHandlers } from "./handlers/persona-handlers.js";
|
||||
@@ -35,6 +36,7 @@ export const createBot = ({
|
||||
|
||||
admin.use(createHelpHandlers());
|
||||
admin.use(createPersonaHandlers(personaService));
|
||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService }));
|
||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||
|
||||
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
|
||||
|
||||
76
src/bot/handlers/context-handlers.ts
Normal file
76
src/bot/handlers/context-handlers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Composer, InlineKeyboard, type Context } from "grammy";
|
||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||
|
||||
const CLEAR_CONFIRM = "clear:yes";
|
||||
|
||||
const CLEAR_CANCEL = "clear:no";
|
||||
|
||||
interface ContextHandlersDeps {
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
}
|
||||
|
||||
const formatDate = (unixSeconds: number): string =>
|
||||
new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
|
||||
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
||||
const visibilityText = async (ctx: Context): Promise<string> => {
|
||||
if (ctx.chat?.type === "private") return "Это личка — я вижу все сообщения.";
|
||||
if (ctx.me.can_read_all_group_messages) return "Privacy mode выключен — я вижу все сообщения группы.";
|
||||
|
||||
const member = await ctx.getChatMember(ctx.me.id).catch(() => undefined);
|
||||
|
||||
if (member?.status === "administrator") return "Я админ группы — вижу все сообщения.";
|
||||
|
||||
return (
|
||||
"⚠️ Privacy mode включён и я не админ — вижу только команды, упоминания и ответы мне.\n" +
|
||||
"Отключи в @BotFather (/setprivacy → Disable) и добавь меня в группу заново, либо сделай админом."
|
||||
);
|
||||
};
|
||||
|
||||
/** Что бот помнит о чате и очистка этой памяти. Подключать только за фильтром администратора. */
|
||||
export const createContextHandlers = ({ chatHistoryService, chatMemberService }: ContextHandlersDeps) => {
|
||||
const composer = new Composer();
|
||||
|
||||
composer.command("context", async (ctx) => {
|
||||
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
|
||||
const members = chatMemberService.list(ctx.chat.id).length;
|
||||
|
||||
const since = oldest === null ? "" : `, с ${formatDate(oldest)} UTC`;
|
||||
|
||||
const lines = [
|
||||
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
||||
`Знаю участников: ${members}.`,
|
||||
await visibilityText(ctx),
|
||||
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт.",
|
||||
"Очистить переписку: /clear",
|
||||
];
|
||||
|
||||
await ctx.reply(lines.join("\n"));
|
||||
});
|
||||
|
||||
composer.command("clear", async (ctx) => {
|
||||
const { count } = chatHistoryService.stats(ctx.chat.id);
|
||||
|
||||
const keyboard = new InlineKeyboard().text("🗑 Да, очистить", CLEAR_CONFIRM).text("Отмена", CLEAR_CANCEL);
|
||||
|
||||
await ctx.reply(`Забыть сохранённую переписку этого чата (${count} сообщений)? Имена участников останутся.`, {
|
||||
reply_markup: keyboard,
|
||||
});
|
||||
});
|
||||
|
||||
composer.callbackQuery(CLEAR_CONFIRM, async (ctx) => {
|
||||
const removed = ctx.chat ? chatHistoryService.clear(ctx.chat.id) : 0;
|
||||
|
||||
await ctx.answerCallbackQuery({ text: "Контекст очищен" });
|
||||
await ctx.editMessageText(`Готово: забыл ${removed} сообщений. Начинаю с чистого листа.`).catch(() => {});
|
||||
});
|
||||
|
||||
composer.callbackQuery(CLEAR_CANCEL, async (ctx) => {
|
||||
await ctx.answerCallbackQuery({ text: "Отменено" });
|
||||
await ctx.editMessageText("Очистка отменена.").catch(() => {});
|
||||
});
|
||||
|
||||
return composer;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import type { AssistantService } from "../../services/assistant/assistant-servic
|
||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||
import type { PersonaService } from "../../services/persona-service.js";
|
||||
import { collectImages, imageFileOf } from "../images.js";
|
||||
import { createReplier, withTyping } from "../reply.js";
|
||||
|
||||
interface QuestionHandlersDeps {
|
||||
@@ -21,6 +22,16 @@ interface QuestionHandlersDeps {
|
||||
chatMemberService: ChatMemberService;
|
||||
}
|
||||
|
||||
/** Прислали только картинку без текста — считаем, что спрашивают про неё. */
|
||||
const IMAGE_ONLY_QUESTION = "Что на изображении?";
|
||||
|
||||
/** `/ask ...` в подписи к фото: grammY ищет команды только в тексте, подписи разбираем сами. */
|
||||
const askInCaption = (caption: string | undefined, username: string): string | undefined => {
|
||||
const command = caption?.match(new RegExp(`^/ask(?:@${username})?(?:\\s+|$)`, "i"));
|
||||
|
||||
return command ? caption!.slice(command[0].length).trim() : undefined;
|
||||
};
|
||||
|
||||
const chatTitle = (ctx: Context): string => {
|
||||
if (!ctx.chat) return "неизвестный чат";
|
||||
|
||||
@@ -56,7 +67,11 @@ export const createQuestionHandlers = ({
|
||||
return chatMemberService.resolveMentions(ctx.chat!.id, { usernames, profiles });
|
||||
};
|
||||
|
||||
const answerQuestion = async (ctx: Context, text: string) => {
|
||||
const answerQuestion = async (ctx: Context, rawText: string) => {
|
||||
const msg = ctx.msg!;
|
||||
const hasImage = imageFileOf(msg) !== undefined || imageFileOf(msg.reply_to_message) !== undefined;
|
||||
const text = rawText || (hasImage ? IMAGE_ONLY_QUESTION : "");
|
||||
|
||||
if (!text) {
|
||||
await reply(ctx, "Задай вопрос: /ask <вопрос>");
|
||||
return;
|
||||
@@ -66,18 +81,23 @@ export const createQuestionHandlers = ({
|
||||
|
||||
const { members, unknownUsernames } = resolveMentions(ctx);
|
||||
|
||||
const question = {
|
||||
chatId,
|
||||
chatTitle: chatTitle(ctx),
|
||||
askerName: displayName(ctx.from!),
|
||||
text,
|
||||
repliedTo: repliedToMessage(ctx),
|
||||
mentionedMembers: members,
|
||||
unknownUsernames,
|
||||
const ask = async () => {
|
||||
const question = {
|
||||
chatId,
|
||||
chatTitle: chatTitle(ctx),
|
||||
askerName: displayName(ctx.from!),
|
||||
text,
|
||||
repliedTo: repliedToMessage(ctx),
|
||||
mentionedMembers: members,
|
||||
unknownUsernames,
|
||||
images: hasImage ? await collectImages(ctx.api, msg) : [],
|
||||
};
|
||||
|
||||
return assistantService.answer(question, personaService.getActive(chatId));
|
||||
};
|
||||
|
||||
try {
|
||||
const answer = await withTyping(ctx, () => assistantService.answer(question, personaService.getActive(chatId)));
|
||||
const answer = await withTyping(ctx, ask);
|
||||
|
||||
await reply(ctx, answer || "Мне нечего ответить 🤷");
|
||||
} catch (error) {
|
||||
@@ -88,10 +108,19 @@ export const createQuestionHandlers = ({
|
||||
|
||||
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
|
||||
|
||||
composer.on("message:text", async (ctx) => {
|
||||
const text = ctx.msg.text;
|
||||
// Текст или подпись к фото: в личке — всегда, в группе — при упоминании бота или ответе на его сообщение.
|
||||
composer.on("message", async (ctx) => {
|
||||
const text = ctx.msg.text ?? ctx.msg.caption ?? "";
|
||||
const hasImage = imageFileOf(ctx.msg) !== undefined;
|
||||
|
||||
if (text.startsWith("/")) return;
|
||||
const captionQuestion = askInCaption(ctx.msg.caption, ctx.me.username);
|
||||
|
||||
if (captionQuestion !== undefined) {
|
||||
await answerQuestion(ctx, captionQuestion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/") || (!text && !hasImage)) return;
|
||||
|
||||
const isPrivate = ctx.chat.type === "private";
|
||||
const mentioned = mentions(text, ctx.me.username);
|
||||
|
||||
61
src/bot/images.ts
Normal file
61
src/bot/images.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Api } from "grammy";
|
||||
import type { Message } from "grammy/types";
|
||||
import type { ImageAttachment } from "../types.js";
|
||||
|
||||
/** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
interface ImageFile {
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/** Картинка в сообщении: фото (берём самый крупный размер) или файл-изображение. */
|
||||
export const imageFileOf = (msg: Message | undefined): ImageFile | undefined => {
|
||||
const photo = msg?.photo?.at(-1);
|
||||
|
||||
if (photo) return { fileId: photo.file_id, mimeType: "image/jpeg" };
|
||||
|
||||
const document = msg?.document;
|
||||
|
||||
if (document?.mime_type?.startsWith("image/")) return { fileId: document.file_id, mimeType: document.mime_type };
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */
|
||||
const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
|
||||
const { file_path: filePath, file_size: fileSize } = await api.getFile(file.fileId);
|
||||
|
||||
if (!filePath) throw new Error("Telegram не отдал путь к файлу");
|
||||
if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`);
|
||||
|
||||
const response = await fetch(`https://api.telegram.org/file/bot${api.token}/${filePath}`);
|
||||
|
||||
if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`);
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
};
|
||||
|
||||
/** Фото из вопроса и из сообщения, на которое ответили. Не скачалось — пропускаем, а не роняем ответ. */
|
||||
export const collectImages = async (api: Api, msg: Message): Promise<ImageAttachment[]> => {
|
||||
const candidates = [
|
||||
{ source: "question" as const, file: imageFileOf(msg) },
|
||||
{ source: "replied" as const, file: imageFileOf(msg.reply_to_message) },
|
||||
];
|
||||
|
||||
const downloaded = await Promise.all(
|
||||
candidates.map(async ({ source, file }) => {
|
||||
if (!file) return undefined;
|
||||
|
||||
try {
|
||||
return { data: await download(api, file), mimeType: file.mimeType, source };
|
||||
} catch (error) {
|
||||
console.error("Не удалось скачать картинку:", error);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return downloaded.filter((image) => image !== undefined);
|
||||
};
|
||||
Reference in New Issue
Block a user