Фото в вопросах, 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

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