Фото в вопросах, 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 в личку)
|
||||
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
|
||||
|
||||
@@ -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/<id>.md
|
||||
```
|
||||
|
||||
13
README.md
13
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 <id>` — выбрать
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -40,9 +40,22 @@ const PROVIDER_API_KEYS: Record<string, string> = {
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
10
src/index.ts
10
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 ? "да" : "нет"}`),
|
||||
});
|
||||
|
||||
@@ -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<typeof createMessageRepository>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 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");
|
||||
};
|
||||
|
||||
@@ -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<typeof createChatHistoryService>;
|
||||
|
||||
@@ -32,3 +32,11 @@ export interface ChatMember {
|
||||
|
||||
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
|
||||
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