From f8273e8a23b9a646e30c8c184425a2e546887294 Mon Sep 17 00:00:00 2001 From: yunogasai Date: Thu, 24 Sep 2026 20:04:06 +0200 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=BE=D1=82=D0=BE=20=D0=B2=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0=D1=85,=20Gemini=203.8=20Fl?= =?UTF-8?q?ash=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E,=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20/c?= =?UTF-8?q?ontext=20=D0=B8=20/clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Бот смотрит фото: подпись с упоминанием, /ask в подписи или ответом на фото, фото в личке - Картинки скачиваются через Bot API и передаются модели байтами (ссылка с токеном наружу не уходит) - OpenRouter по умолчанию — openrouter/google/gemini-3.8-flash; AI_IMAGE_INPUT для явного выбора - Модель без зрения получает пометку, что фото есть, но она его не видит - /context: сколько бот помнит и видит ли всю переписку (privacy mode / админ) - /clear: очистка сохранённой переписки чата с подтверждением Co-Authored-By: Claude Opus 5.5 --- .env.example | 14 ++-- CLAUDE.md | 4 +- README.md | 13 ++-- src/bot/commands.ts | 8 +++ src/bot/create-bot.ts | 2 + src/bot/handlers/context-handlers.ts | 76 +++++++++++++++++++++ src/bot/handlers/question-handlers.ts | 55 +++++++++++---- src/bot/images.ts | 61 +++++++++++++++++ src/config.ts | 37 +++++++--- src/index.ts | 10 ++- src/repositories/message-repository.ts | 11 ++- src/services/assistant/assistant-service.ts | 22 +++++- src/services/assistant/prompts.ts | 31 +++++++-- src/services/chat-history-service.ts | 7 +- src/types.ts | 8 +++ 15 files changed, 313 insertions(+), 46 deletions(-) create mode 100644 src/bot/handlers/context-handlers.ts create mode 100644 src/bot/images.ts diff --git a/.env.example b/.env.example index 777c5ff..826bc73 100644 --- a/.env.example +++ b/.env.example @@ -4,15 +4,17 @@ BOT_TOKEN= # Telegram ID администраторов через запятую (узнать свой: написать боту /whoami в личку) ADMIN_IDS= -# Провайдер модели — задай ключ одного из них. -# DeepSeek напрямую (если ключ задан, по умолчанию берётся deepseek/deepseek-flash): -DEEPSEEK_API_KEY= -# Или OpenRouter (по умолчанию openrouter/deepseek/deepseek-v4-flash): -# OPENROUTER_API_KEY= +# Провайдер модели — задай ключ хотя бы одного. +# OpenRouter: по умолчанию openrouter/google/gemini-3.8-flash — умеет смотреть фото. +OPENROUTER_API_KEY= +# DeepSeek напрямую: по умолчанию deepseek/deepseek-flash (только текст), если ключа OpenRouter нет. +# DEEPSEEK_API_KEY= # Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>, -# например deepseek/deepseek-v4-pro или openrouter/deepseek/deepseek-v4-pro +# например deepseek/deepseek-v4-pro или openrouter/google/gemini-3.8-flash # AI_MODEL= +# Необязательно: умеет ли модель смотреть фото (по умолчанию — да для gemini/vision-моделей) +# AI_IMAGE_INPUT=true # Путь к SQLite-базе и сколько сообщений на чат хранить DB_PATH=data/bot.db diff --git a/CLAUDE.md b/CLAUDE.md index 7e2898a..89c5419 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Boltun AI -Telegram-бот на Node.js + TypeScript + grammY, AI через Mastra (DeepSeek напрямую или через OpenRouter). Хранилище — SQLite (better-sqlite3). +Telegram-бот на Node.js + TypeScript + grammY, AI через Mastra (OpenRouter — по умолчанию Gemini 3.8 Flash, или DeepSeek напрямую). Хранилище — SQLite (better-sqlite3). ## Команды @@ -25,7 +25,7 @@ src/ repositories/ — только SQL и маппинг строк в доменные типы, по репозиторию на таблицу services/ — бизнес-логика; зависят от репозиториев, не знают про grammY assistant/ — AI на Mastra: агент, промпты, tools - bot/ — grammY: сборка бота, общие хелперы ответа + bot/ — grammY: сборка бота, общие хелперы ответа, скачивание фото handlers/ — тонкие Composer'ы: разбор команды → вызов сервиса → ответ prompts/ — тексты промптов: system.md (правила бота) и personas/.md ``` diff --git a/README.md b/README.md index 3885407..467b6f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Boltun AI -Telegram-бот для групповых чатов с AI-личностями. Стек: Node.js, TypeScript, [grammY](https://grammy.dev), [Mastra](https://mastra.ai) (DeepSeek напрямую или через OpenRouter), SQLite. +Telegram-бот для групповых чатов с AI-личностями. Стек: Node.js, TypeScript, [grammY](https://grammy.dev), [Mastra](https://mastra.ai) (OpenRouter или DeepSeek напрямую), SQLite. ## Возможности @@ -8,6 +8,8 @@ Telegram-бот для групповых чатов с AI-личностями. - **Вопросы**: `/ask <вопрос>`, упоминание `@бота`, ответ на сообщение бота, в личке — любое сообщение. - **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. +- **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет. +- **Память чата**: `/context` — сколько сообщений и участников бот помнит и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются). - **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D). - `/personas` — список и выбор кнопками - `/persona ` — выбрать @@ -27,7 +29,7 @@ Telegram-бот для групповых чатов с AI-личностями. ```bash nvm use # Node 24 pnpm install -cp .env.example .env # заполнить BOT_TOKEN, ADMIN_IDS и DEEPSEEK_API_KEY (или OPENROUTER_API_KEY) +cp .env.example .env # заполнить BOT_TOKEN, ADMIN_IDS и OPENROUTER_API_KEY (или DEEPSEEK_API_KEY) pnpm dev ``` @@ -39,9 +41,10 @@ pnpm dev | --- | --- | --- | | `BOT_TOKEN` | да | токен от @BotFather | | `ADMIN_IDS` | да | Telegram ID админов через запятую | -| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek — модель идёт напрямую в api.deepseek.com | -| `OPENROUTER_API_KEY` | да* | ключ OpenRouter — альтернатива DeepSeek напрямую | -| `AI_MODEL` | нет | модель Mastra model router `<провайдер>/<модель>`. По умолчанию `deepseek/deepseek-flash`, если задан `DEEPSEEK_API_KEY`, иначе `openrouter/deepseek/deepseek-v4-flash`. Пример посильнее: `deepseek/deepseek-v4-pro` | +| `OPENROUTER_API_KEY` | да* | ключ OpenRouter; модель по умолчанию — `openrouter/google/gemini-3.8-flash` (видит фото) | +| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek напрямую (api.deepseek.com); используется по умолчанию, если ключа OpenRouter нет — модель `deepseek/deepseek-flash`, только текст | +| `AI_MODEL` | нет | явная модель Mastra model router `<провайдер>/<модель>`, например `deepseek/deepseek-v4-pro` | +| `AI_IMAGE_INPUT` | нет | `true`/`false` — отправлять ли модели фото. По умолчанию `true` для моделей с `gemini`/`vision` в названии | \* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте. | `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) | diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 049be76..eb13f6c 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -2,6 +2,8 @@ export const BOT_COMMANDS = [ { command: "ask", description: "Задать вопрос боту" }, { command: "personas", description: "Список личностей и выбор" }, { command: "persona", description: "Выбрать личность: /persona " }, + { 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 — список и выбор кнопками diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 446e0e6..1accfb3 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -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 не отвалится по таймауту. diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts new file mode 100644 index 0000000..e3c51d9 --- /dev/null +++ b/src/bot/handlers/context-handlers.ts @@ -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 => { + 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; +}; diff --git a/src/bot/handlers/question-handlers.ts b/src/bot/handlers/question-handlers.ts index 3d1c0c8..4334ff0 100644 --- a/src/bot/handlers/question-handlers.ts +++ b/src/bot/handlers/question-handlers.ts @@ -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); diff --git a/src/bot/images.ts b/src/bot/images.ts new file mode 100644 index 0000000..416b179 --- /dev/null +++ b/src/bot/images.ts @@ -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 => { + 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 => { + 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); +}; diff --git a/src/config.ts b/src/config.ts index 38ca66e..eb723a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,9 +40,22 @@ const PROVIDER_API_KEYS: Record = { openrouter: "OPENROUTER_API_KEY", }; -/** Есть ключ DeepSeek — ходим в DeepSeek напрямую, иначе через OpenRouter. */ +/** Есть ключ OpenRouter — Gemini 3.8 Flash (умеет фото), иначе DeepSeek напрямую (только текст). */ const defaultModel = (): string => - process.env.DEEPSEEK_API_KEY?.trim() ? "deepseek/deepseek-flash" : "openrouter/deepseek/deepseek-v4-flash"; + process.env.OPENROUTER_API_KEY?.trim() ? "openrouter/google/gemini-3.8-flash" : "deepseek/deepseek-flash"; + +const parseBool = (name: string): boolean | undefined => { + const raw = process.env[name]?.trim().toLowerCase(); + + if (!raw) return undefined; + if (["1", "true", "yes"].includes(raw)) return true; + if (["0", "false", "no"].includes(raw)) return false; + + throw new Error(`${name} должно быть true или false, получено "${raw}"`); +}; + +/** Умеет ли модель смотреть изображения: AI_IMAGE_INPUT, иначе угадываем по названию модели. */ +const parseImageInput = (model: string): boolean => parseBool("AI_IMAGE_INPUT") ?? /gemini|vision/i.test(model); /** Модель из AI_MODEL (или по умолчанию) + проверка, что ключ её провайдера задан. */ const parseModel = (): string => { @@ -65,10 +78,16 @@ const parseModel = (): string => { /** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */ export const DB_PATH = process.env.DB_PATH?.trim() || "data/bot.db"; -export const loadBotConfig = () => ({ - botToken: required("BOT_TOKEN"), - adminIds: parseAdminIds(required("ADMIN_IDS")), - aiModel: parseModel(), - /** Сколько последних сообщений каждого чата хранить в базе. */ - historyLimit: positiveInt("HISTORY_LIMIT", 2000), -}); +export const loadBotConfig = () => { + const aiModel = parseModel(); + + return { + botToken: required("BOT_TOKEN"), + adminIds: parseAdminIds(required("ADMIN_IDS")), + aiModel, + /** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */ + aiImageInput: parseImageInput(aiModel), + /** Сколько последних сообщений каждого чата хранить в базе. */ + historyLimit: positiveInt("HISTORY_LIMIT", 2000), + }; +}; diff --git a/src/index.ts b/src/index.ts index 19e0fcb..9bb1b8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,12 @@ const chatHistoryService = createChatHistoryService({ const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) }); -const assistantService = createAssistantService({ model: config.aiModel, chatHistoryService, chatMemberService }); +const assistantService = createAssistantService({ + model: config.aiModel, + imageInput: config.aiImageInput, + chatHistoryService, + chatMemberService, +}); const bot = createBot({ token: config.botToken, @@ -53,5 +58,6 @@ await bot.api.setMyCommands(BOT_COMMANDS); await bot.start({ allowed_updates: ["message", "edited_message", "callback_query"], - onStart: (me) => console.log(`Бот @${me.username} запущен, модель ${config.aiModel}`), + onStart: (me) => + console.log(`Бот @${me.username} запущен, модель ${config.aiModel}, фото: ${config.aiImageInput ? "да" : "нет"}`), }); diff --git a/src/repositories/message-repository.ts b/src/repositories/message-repository.ts index b5ebd71..d7d5158 100644 --- a/src/repositories/message-repository.ts +++ b/src/repositories/message-repository.ts @@ -39,6 +39,8 @@ export const createMessageRepository = (db: Db) => { "SELECT * FROM messages WHERE chat_id = ? AND user_id = ? ORDER BY message_id DESC LIMIT ?", ), findById: db.prepare("SELECT * FROM messages WHERE chat_id = ? AND message_id = ?"), + statsByChat: db.prepare("SELECT COUNT(*) AS count, MIN(date) AS oldest FROM messages WHERE chat_id = ?"), + deleteByChat: db.prepare("DELETE FROM messages WHERE chat_id = ?"), }; const toChronological = (rows: unknown[]): ChatMessage[] => (rows as MessageRow[]).reverse().map(toMessage); @@ -63,7 +65,14 @@ export const createMessageRepository = (db: Db) => { return row && toMessage(row); }; - return { upsertAndPrune, latest, latestByUser, findById }; + /** Сколько сообщений чата сохранено и дата самого старого (null — если пусто). */ + const statsByChat = (chatId: number): { count: number; oldest: number | null } => + statements.statsByChat.get(chatId) as { count: number; oldest: number | null }; + + /** Удаляет всю сохранённую историю чата; возвращает, сколько сообщений удалено. */ + const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes; + + return { upsertAndPrune, latest, latestByUser, findById, statsByChat, deleteByChat }; }; export type MessageRepository = ReturnType; diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index 9baa1d8..b671abe 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -14,13 +14,31 @@ export type { Question } from "./prompts.js"; interface AssistantServiceDeps { model: string; + /** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */ + imageInput: boolean; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; } const MAX_AGENT_STEPS = 6; -export const createAssistantService = ({ model, chatHistoryService, chatMemberService }: AssistantServiceDeps) => { +/** Сообщение пользователя для модели: текст вопроса и, если модель умеет, картинки. */ +const buildUserMessage = (question: Question, imageInput: boolean) => ({ + role: "user" as const, + content: [ + { type: "text" as const, text: buildPrompt(question, imageInput) }, + ...(imageInput + ? question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })) + : []), + ], +}); + +export const createAssistantService = ({ + model, + imageInput, + chatHistoryService, + chatMemberService, +}: AssistantServiceDeps) => { const systemRules = readPrompt("system.md"); const agent = new Agent({ @@ -41,7 +59,7 @@ export const createAssistantService = ({ model, chatHistoryService, chatMemberSe requestContext.set("chatId", question.chatId); - const result = await agent.generate(buildPrompt(question), { + const result = await agent.generate([buildUserMessage(question, imageInput)], { instructions: buildInstructions(systemRules, question, persona), requestContext, maxSteps: MAX_AGENT_STEPS, diff --git a/src/services/assistant/prompts.ts b/src/services/assistant/prompts.ts index 6de3f4f..3252de6 100644 --- a/src/services/assistant/prompts.ts +++ b/src/services/assistant/prompts.ts @@ -1,4 +1,4 @@ -import type { ChatMember, ChatMessage, Persona } from "../../types.js"; +import type { ChatMember, ChatMessage, ImageAttachment, Persona } from "../../types.js"; import { formatChatMembers } from "./format-chat-members.js"; import { formatChatMessages } from "./format-chat-messages.js"; @@ -14,8 +14,24 @@ export interface Question { mentionedMembers: ChatMember[]; /** Упомянутые ники, которых бот в чате не встречал. */ unknownUsernames: string[]; + /** Фото из вопроса и из сообщения, на которое ответили. */ + images: ImageAttachment[]; } +const IMAGE_SOURCES: Record = { + question: "из вопроса", + replied: "из сообщения, на которое ответили", +}; + +/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */ +const imagesNote = (images: ImageAttachment[], canSeeImages: boolean): string | undefined => { + if (images.length === 0) return undefined; + + if (!canSeeImages) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом."; + + return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`; +}; + const mentionsSection = (question: Question): string | undefined => { const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : ""; @@ -38,7 +54,12 @@ export const buildInstructions = (systemRules: string, question: Question, perso .filter(Boolean) .join("\n\n"); -export const buildPrompt = (question: Question): string => - question.repliedTo - ? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}\n\nВопрос: ${question.text}` - : question.text; +export const buildPrompt = (question: Question, canSeeImages: boolean): string => { + const replied = question.repliedTo + ? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}` + : undefined; + + const text = replied ? `Вопрос: ${question.text}` : question.text; + + return [replied, imagesNote(question.images, canSeeImages), text].filter(Boolean).join("\n\n"); +}; diff --git a/src/services/chat-history-service.ts b/src/services/chat-history-service.ts index 1cc7323..6c34f7b 100644 --- a/src/services/chat-history-service.ts +++ b/src/services/chat-history-service.ts @@ -19,7 +19,12 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch const find = (chatId: number, messageId: number): ChatMessage | undefined => messageRepository.findById(chatId, messageId); - return { record, latest, latestByUser, find }; + const stats = (chatId: number) => ({ ...messageRepository.statsByChat(chatId), limit: historyLimit }); + + /** Забыть переписку чата: модель больше не увидит сообщения, сохранённые до этого момента. */ + const clear = (chatId: number): number => messageRepository.deleteByChat(chatId); + + return { record, latest, latestByUser, find, stats, clear }; }; export type ChatHistoryService = ReturnType; diff --git a/src/types.ts b/src/types.ts index e9a4cf8..cad4bcb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,3 +32,11 @@ export interface ChatMember { /** Данные пользователя Telegram, которые бот запоминает об участнике. */ export type MemberProfile = Pick; + +/** Картинка, которую бот передаёт модели вместе с вопросом. */ +export interface ImageAttachment { + data: Uint8Array; + mimeType: string; + /** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */ + source: "question" | "replied"; +}