Фото в вопросах, 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:
14
.env.example
14
.env.example
@@ -4,15 +4,17 @@ BOT_TOKEN=
|
|||||||
# Telegram ID администраторов через запятую (узнать свой: написать боту /whoami в личку)
|
# Telegram ID администраторов через запятую (узнать свой: написать боту /whoami в личку)
|
||||||
ADMIN_IDS=
|
ADMIN_IDS=
|
||||||
|
|
||||||
# Провайдер модели — задай ключ одного из них.
|
# Провайдер модели — задай ключ хотя бы одного.
|
||||||
# DeepSeek напрямую (если ключ задан, по умолчанию берётся deepseek/deepseek-flash):
|
# OpenRouter: по умолчанию openrouter/google/gemini-3.8-flash — умеет смотреть фото.
|
||||||
DEEPSEEK_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
# Или OpenRouter (по умолчанию openrouter/deepseek/deepseek-v4-flash):
|
# DeepSeek напрямую: по умолчанию deepseek/deepseek-flash (только текст), если ключа OpenRouter нет.
|
||||||
# OPENROUTER_API_KEY=
|
# DEEPSEEK_API_KEY=
|
||||||
|
|
||||||
# Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>,
|
# Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>,
|
||||||
# например deepseek/deepseek-v4-pro или openrouter/deepseek/deepseek-v4-pro
|
# например deepseek/deepseek-v4-pro или openrouter/google/gemini-3.8-flash
|
||||||
# AI_MODEL=
|
# AI_MODEL=
|
||||||
|
# Необязательно: умеет ли модель смотреть фото (по умолчанию — да для gemini/vision-моделей)
|
||||||
|
# AI_IMAGE_INPUT=true
|
||||||
|
|
||||||
# Путь к SQLite-базе и сколько сообщений на чат хранить
|
# Путь к SQLite-базе и сколько сообщений на чат хранить
|
||||||
DB_PATH=data/bot.db
|
DB_PATH=data/bot.db
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Boltun AI
|
# 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 и маппинг строк в доменные типы, по репозиторию на таблицу
|
repositories/ — только SQL и маппинг строк в доменные типы, по репозиторию на таблицу
|
||||||
services/ — бизнес-логика; зависят от репозиториев, не знают про grammY
|
services/ — бизнес-логика; зависят от репозиториев, не знают про grammY
|
||||||
assistant/ — AI на Mastra: агент, промпты, tools
|
assistant/ — AI на Mastra: агент, промпты, tools
|
||||||
bot/ — grammY: сборка бота, общие хелперы ответа
|
bot/ — grammY: сборка бота, общие хелперы ответа, скачивание фото
|
||||||
handlers/ — тонкие Composer'ы: разбор команды → вызов сервиса → ответ
|
handlers/ — тонкие Composer'ы: разбор команды → вызов сервиса → ответ
|
||||||
prompts/ — тексты промптов: system.md (правила бота) и personas/<id>.md
|
prompts/ — тексты промптов: system.md (правила бота) и personas/<id>.md
|
||||||
```
|
```
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Boltun AI
|
# 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 <вопрос>`, упоминание `@бота`, ответ на сообщение бота, в личке — любое сообщение.
|
- **Вопросы**: `/ask <вопрос>`, упоминание `@бота`, ответ на сообщение бота, в личке — любое сообщение.
|
||||||
- **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст.
|
- **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст.
|
||||||
- **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени.
|
- **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени.
|
||||||
|
- **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет.
|
||||||
|
- **Память чата**: `/context` — сколько сообщений и участников бот помнит и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются).
|
||||||
- **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D).
|
- **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D).
|
||||||
- `/personas` — список и выбор кнопками
|
- `/personas` — список и выбор кнопками
|
||||||
- `/persona <id>` — выбрать
|
- `/persona <id>` — выбрать
|
||||||
@@ -27,7 +29,7 @@ Telegram-бот для групповых чатов с AI-личностями.
|
|||||||
```bash
|
```bash
|
||||||
nvm use # Node 24
|
nvm use # Node 24
|
||||||
pnpm install
|
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
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -39,9 +41,10 @@ pnpm dev
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `BOT_TOKEN` | да | токен от @BotFather |
|
| `BOT_TOKEN` | да | токен от @BotFather |
|
||||||
| `ADMIN_IDS` | да | Telegram ID админов через запятую |
|
| `ADMIN_IDS` | да | Telegram ID админов через запятую |
|
||||||
| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek — модель идёт напрямую в api.deepseek.com |
|
| `OPENROUTER_API_KEY` | да* | ключ OpenRouter; модель по умолчанию — `openrouter/google/gemini-3.8-flash` (видит фото) |
|
||||||
| `OPENROUTER_API_KEY` | да* | ключ OpenRouter — альтернатива DeepSeek напрямую |
|
| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek напрямую (api.deepseek.com); используется по умолчанию, если ключа OpenRouter нет — модель `deepseek/deepseek-flash`, только текст |
|
||||||
| `AI_MODEL` | нет | модель Mastra model router `<провайдер>/<модель>`. По умолчанию `deepseek/deepseek-flash`, если задан `DEEPSEEK_API_KEY`, иначе `openrouter/deepseek/deepseek-v4-flash`. Пример посильнее: `deepseek/deepseek-v4-pro` |
|
| `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`) |
|
| `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export const BOT_COMMANDS = [
|
|||||||
{ command: "ask", description: "Задать вопрос боту" },
|
{ command: "ask", description: "Задать вопрос боту" },
|
||||||
{ command: "personas", description: "Список личностей и выбор" },
|
{ command: "personas", description: "Список личностей и выбор" },
|
||||||
{ command: "persona", description: "Выбрать личность: /persona <id>" },
|
{ command: "persona", description: "Выбрать личность: /persona <id>" },
|
||||||
|
{ command: "context", description: "Что бот помнит о чате и видит ли переписку" },
|
||||||
|
{ command: "clear", description: "Очистить сохранённую переписку чата" },
|
||||||
{ command: "help", description: "Справка" },
|
{ command: "help", description: "Справка" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -14,7 +16,13 @@ export const helpText = (username: string) => `Я отвечаю только а
|
|||||||
|
|
||||||
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
|
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
|
||||||
Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?».
|
Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?».
|
||||||
|
Фото тоже смотрю: пришли фото с подписью, где упомянут я, или ответь на фото командой /ask.
|
||||||
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
|
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
|
||||||
|
Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт.
|
||||||
|
|
||||||
|
Память:
|
||||||
|
• /context — что я помню о чате и вижу ли всю переписку
|
||||||
|
• /clear — забыть сохранённую переписку
|
||||||
|
|
||||||
Личности:
|
Личности:
|
||||||
• /personas — список и выбор кнопками
|
• /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 { ChatHistoryService } from "../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../services/chat-member-service.js";
|
import type { ChatMemberService } from "../services/chat-member-service.js";
|
||||||
import type { PersonaService } from "../services/persona-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 { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
|
||||||
import { createHistoryHandlers } from "./handlers/history-handlers.js";
|
import { createHistoryHandlers } from "./handlers/history-handlers.js";
|
||||||
import { createPersonaHandlers } from "./handlers/persona-handlers.js";
|
import { createPersonaHandlers } from "./handlers/persona-handlers.js";
|
||||||
@@ -35,6 +36,7 @@ export const createBot = ({
|
|||||||
|
|
||||||
admin.use(createHelpHandlers());
|
admin.use(createHelpHandlers());
|
||||||
admin.use(createPersonaHandlers(personaService));
|
admin.use(createPersonaHandlers(personaService));
|
||||||
|
admin.use(createContextHandlers({ chatHistoryService, chatMemberService }));
|
||||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||||
|
|
||||||
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
|
// Иначе у не-админа кнопка «крутится», пока 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 { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||||
import type { PersonaService } from "../../services/persona-service.js";
|
import type { PersonaService } from "../../services/persona-service.js";
|
||||||
|
import { collectImages, imageFileOf } from "../images.js";
|
||||||
import { createReplier, withTyping } from "../reply.js";
|
import { createReplier, withTyping } from "../reply.js";
|
||||||
|
|
||||||
interface QuestionHandlersDeps {
|
interface QuestionHandlersDeps {
|
||||||
@@ -21,6 +22,16 @@ interface QuestionHandlersDeps {
|
|||||||
chatMemberService: ChatMemberService;
|
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 => {
|
const chatTitle = (ctx: Context): string => {
|
||||||
if (!ctx.chat) return "неизвестный чат";
|
if (!ctx.chat) return "неизвестный чат";
|
||||||
|
|
||||||
@@ -56,7 +67,11 @@ export const createQuestionHandlers = ({
|
|||||||
return chatMemberService.resolveMentions(ctx.chat!.id, { usernames, profiles });
|
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) {
|
if (!text) {
|
||||||
await reply(ctx, "Задай вопрос: /ask <вопрос>");
|
await reply(ctx, "Задай вопрос: /ask <вопрос>");
|
||||||
return;
|
return;
|
||||||
@@ -66,6 +81,7 @@ export const createQuestionHandlers = ({
|
|||||||
|
|
||||||
const { members, unknownUsernames } = resolveMentions(ctx);
|
const { members, unknownUsernames } = resolveMentions(ctx);
|
||||||
|
|
||||||
|
const ask = async () => {
|
||||||
const question = {
|
const question = {
|
||||||
chatId,
|
chatId,
|
||||||
chatTitle: chatTitle(ctx),
|
chatTitle: chatTitle(ctx),
|
||||||
@@ -74,10 +90,14 @@ export const createQuestionHandlers = ({
|
|||||||
repliedTo: repliedToMessage(ctx),
|
repliedTo: repliedToMessage(ctx),
|
||||||
mentionedMembers: members,
|
mentionedMembers: members,
|
||||||
unknownUsernames,
|
unknownUsernames,
|
||||||
|
images: hasImage ? await collectImages(ctx.api, msg) : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return assistantService.answer(question, personaService.getActive(chatId));
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const answer = await withTyping(ctx, () => assistantService.answer(question, personaService.getActive(chatId)));
|
const answer = await withTyping(ctx, ask);
|
||||||
|
|
||||||
await reply(ctx, answer || "Мне нечего ответить 🤷");
|
await reply(ctx, answer || "Мне нечего ответить 🤷");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,10 +108,19 @@ export const createQuestionHandlers = ({
|
|||||||
|
|
||||||
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
|
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 isPrivate = ctx.chat.type === "private";
|
||||||
const mentioned = mentions(text, ctx.me.username);
|
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);
|
||||||
|
};
|
||||||
@@ -40,9 +40,22 @@ const PROVIDER_API_KEYS: Record<string, string> = {
|
|||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ключ DeepSeek — ходим в DeepSeek напрямую, иначе через OpenRouter. */
|
/** Есть ключ OpenRouter — Gemini 3.8 Flash (умеет фото), иначе DeepSeek напрямую (только текст). */
|
||||||
const defaultModel = (): string =>
|
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 (или по умолчанию) + проверка, что ключ её провайдера задан. */
|
/** Модель из AI_MODEL (или по умолчанию) + проверка, что ключ её провайдера задан. */
|
||||||
const parseModel = (): string => {
|
const parseModel = (): string => {
|
||||||
@@ -65,10 +78,16 @@ const parseModel = (): string => {
|
|||||||
/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */
|
/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */
|
||||||
export const DB_PATH = process.env.DB_PATH?.trim() || "data/bot.db";
|
export const DB_PATH = process.env.DB_PATH?.trim() || "data/bot.db";
|
||||||
|
|
||||||
export const loadBotConfig = () => ({
|
export const loadBotConfig = () => {
|
||||||
|
const aiModel = parseModel();
|
||||||
|
|
||||||
|
return {
|
||||||
botToken: required("BOT_TOKEN"),
|
botToken: required("BOT_TOKEN"),
|
||||||
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
||||||
aiModel: parseModel(),
|
aiModel,
|
||||||
|
/** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */
|
||||||
|
aiImageInput: parseImageInput(aiModel),
|
||||||
/** Сколько последних сообщений каждого чата хранить в базе. */
|
/** Сколько последних сообщений каждого чата хранить в базе. */
|
||||||
historyLimit: positiveInt("HISTORY_LIMIT", 2000),
|
historyLimit: positiveInt("HISTORY_LIMIT", 2000),
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -29,7 +29,12 @@ const chatHistoryService = createChatHistoryService({
|
|||||||
|
|
||||||
const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) });
|
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({
|
const bot = createBot({
|
||||||
token: config.botToken,
|
token: config.botToken,
|
||||||
@@ -53,5 +58,6 @@ await bot.api.setMyCommands(BOT_COMMANDS);
|
|||||||
|
|
||||||
await bot.start({
|
await bot.start({
|
||||||
allowed_updates: ["message", "edited_message", "callback_query"],
|
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 ? "да" : "нет"}`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const createMessageRepository = (db: Db) => {
|
|||||||
"SELECT * FROM messages WHERE chat_id = ? AND user_id = ? ORDER BY message_id DESC LIMIT ?",
|
"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 = ?"),
|
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);
|
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 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<typeof createMessageRepository>;
|
export type MessageRepository = ReturnType<typeof createMessageRepository>;
|
||||||
|
|||||||
@@ -14,13 +14,31 @@ export type { Question } from "./prompts.js";
|
|||||||
|
|
||||||
interface AssistantServiceDeps {
|
interface AssistantServiceDeps {
|
||||||
model: string;
|
model: string;
|
||||||
|
/** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */
|
||||||
|
imageInput: boolean;
|
||||||
chatHistoryService: ChatHistoryService;
|
chatHistoryService: ChatHistoryService;
|
||||||
chatMemberService: ChatMemberService;
|
chatMemberService: ChatMemberService;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_AGENT_STEPS = 6;
|
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 systemRules = readPrompt("system.md");
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
@@ -41,7 +59,7 @@ export const createAssistantService = ({ model, chatHistoryService, chatMemberSe
|
|||||||
|
|
||||||
requestContext.set("chatId", question.chatId);
|
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),
|
instructions: buildInstructions(systemRules, question, persona),
|
||||||
requestContext,
|
requestContext,
|
||||||
maxSteps: MAX_AGENT_STEPS,
|
maxSteps: MAX_AGENT_STEPS,
|
||||||
|
|||||||
@@ -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 { formatChatMembers } from "./format-chat-members.js";
|
||||||
import { formatChatMessages } from "./format-chat-messages.js";
|
import { formatChatMessages } from "./format-chat-messages.js";
|
||||||
|
|
||||||
@@ -14,8 +14,24 @@ export interface Question {
|
|||||||
mentionedMembers: ChatMember[];
|
mentionedMembers: ChatMember[];
|
||||||
/** Упомянутые ники, которых бот в чате не встречал. */
|
/** Упомянутые ники, которых бот в чате не встречал. */
|
||||||
unknownUsernames: string[];
|
unknownUsernames: string[];
|
||||||
|
/** Фото из вопроса и из сообщения, на которое ответили. */
|
||||||
|
images: ImageAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_SOURCES: Record<ImageAttachment["source"], string> = {
|
||||||
|
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 mentionsSection = (question: Question): string | undefined => {
|
||||||
const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : "";
|
const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : "";
|
||||||
|
|
||||||
@@ -38,7 +54,12 @@ export const buildInstructions = (systemRules: string, question: Question, perso
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
export const buildPrompt = (question: Question): string =>
|
export const buildPrompt = (question: Question, canSeeImages: boolean): string => {
|
||||||
question.repliedTo
|
const replied = question.repliedTo
|
||||||
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}\n\nВопрос: ${question.text}`
|
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
|
||||||
: question.text;
|
: undefined;
|
||||||
|
|
||||||
|
const text = replied ? `Вопрос: ${question.text}` : question.text;
|
||||||
|
|
||||||
|
return [replied, imagesNote(question.images, canSeeImages), text].filter(Boolean).join("\n\n");
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch
|
|||||||
const find = (chatId: number, messageId: number): ChatMessage | undefined =>
|
const find = (chatId: number, messageId: number): ChatMessage | undefined =>
|
||||||
messageRepository.findById(chatId, messageId);
|
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<typeof createChatHistoryService>;
|
export type ChatHistoryService = ReturnType<typeof createChatHistoryService>;
|
||||||
|
|||||||
@@ -32,3 +32,11 @@ export interface ChatMember {
|
|||||||
|
|
||||||
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
|
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
|
||||||
export type MemberProfile = Pick<ChatMember, "userId" | "username" | "firstName" | "lastName">;
|
export type MemberProfile = Pick<ChatMember, "userId" | "username" | "firstName" | "lastName">;
|
||||||
|
|
||||||
|
/** Картинка, которую бот передаёт модели вместе с вопросом. */
|
||||||
|
export interface ImageAttachment {
|
||||||
|
data: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */
|
||||||
|
source: "question" | "replied";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user