Фото в вопросах, 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:
yunogasai
2026-09-24 20:04:06 +02:00
parent 34a8d426d2
commit f8273e8a23
15 changed files with 313 additions and 46 deletions

View File

@@ -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 — список и выбор кнопками

View File

@@ -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 не отвалится по таймауту.

View 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;
};

View File

@@ -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
View 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);
};