Фото в вопросах, Gemini 3.8 Flash по умолчанию, команды /context и /clear

- Бот смотрит фото: подпись с упоминанием, /ask в подписи или ответом на фото, фото в личке
- Картинки скачиваются через Bot API и передаются модели байтами (ссылка с токеном наружу не уходит)
- OpenRouter по умолчанию — openrouter/google/gemini-3.8-flash; AI_IMAGE_INPUT для явного выбора
- Модель без зрения получает пометку, что фото есть, но она его не видит
- /context: сколько бот помнит и видит ли всю переписку (privacy mode / админ)
- /clear: очистка сохранённой переписки чата с подтверждением

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 20:04:06 +02:00
parent 34a8d426d2
commit f8273e8a23
15 changed files with 313 additions and 46 deletions

View File

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

View File

@@ -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
```

View File

@@ -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`) |

View File

@@ -2,6 +2,8 @@ export const BOT_COMMANDS = [
{ command: "ask", description: "Задать вопрос боту" },
{ command: "personas", description: "Список личностей и выбор" },
{ command: "persona", description: "Выбрать личность: /persona <id>" },
{ command: "context", description: "Что бот помнит о чате и видит ли переписку" },
{ command: "clear", description: "Очистить сохранённую переписку чата" },
{ command: "help", description: "Справка" },
];
@@ -14,7 +16,13 @@ export const helpText = (username: string) => `Я отвечаю только а
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?».
Фото тоже смотрю: пришли фото с подписью, где упомянут я, или ответь на фото командой /ask.
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт.
Память:
• /context — что я помню о чате и вижу ли всю переписку
• /clear — забыть сохранённую переписку
Личности:
• /personas — список и выбор кнопками

View File

@@ -3,6 +3,7 @@ import type { AssistantService } from "../services/assistant/assistant-service.j
import type { ChatHistoryService } from "../services/chat-history-service.js";
import type { ChatMemberService } from "../services/chat-member-service.js";
import type { PersonaService } from "../services/persona-service.js";
import { createContextHandlers } from "./handlers/context-handlers.js";
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
import { createHistoryHandlers } from "./handlers/history-handlers.js";
import { createPersonaHandlers } from "./handlers/persona-handlers.js";
@@ -35,6 +36,7 @@ export const createBot = ({
admin.use(createHelpHandlers());
admin.use(createPersonaHandlers(personaService));
admin.use(createContextHandlers({ chatHistoryService, chatMemberService }));
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.

View File

@@ -0,0 +1,76 @@
import { Composer, InlineKeyboard, type Context } from "grammy";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { ChatMemberService } from "../../services/chat-member-service.js";
const CLEAR_CONFIRM = "clear:yes";
const CLEAR_CANCEL = "clear:no";
interface ContextHandlersDeps {
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
}
const formatDate = (unixSeconds: number): string =>
new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
const visibilityText = async (ctx: Context): Promise<string> => {
if (ctx.chat?.type === "private") return "Это личка — я вижу все сообщения.";
if (ctx.me.can_read_all_group_messages) return "Privacy mode выключен — я вижу все сообщения группы.";
const member = await ctx.getChatMember(ctx.me.id).catch(() => undefined);
if (member?.status === "administrator") return "Я админ группы — вижу все сообщения.";
return (
"⚠️ Privacy mode включён и я не админ — вижу только команды, упоминания и ответы мне.\n" +
"Отключи в @BotFather (/setprivacy → Disable) и добавь меня в группу заново, либо сделай админом."
);
};
/** Что бот помнит о чате и очистка этой памяти. Подключать только за фильтром администратора. */
export const createContextHandlers = ({ chatHistoryService, chatMemberService }: ContextHandlersDeps) => {
const composer = new Composer();
composer.command("context", async (ctx) => {
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
const members = chatMemberService.list(ctx.chat.id).length;
const since = oldest === null ? "" : `, с ${formatDate(oldest)} UTC`;
const lines = [
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
`Знаю участников: ${members}.`,
await visibilityText(ctx),
"Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт.",
"Очистить переписку: /clear",
];
await ctx.reply(lines.join("\n"));
});
composer.command("clear", async (ctx) => {
const { count } = chatHistoryService.stats(ctx.chat.id);
const keyboard = new InlineKeyboard().text("🗑 Да, очистить", CLEAR_CONFIRM).text("Отмена", CLEAR_CANCEL);
await ctx.reply(`Забыть сохранённую переписку этого чата (${count} сообщений)? Имена участников останутся.`, {
reply_markup: keyboard,
});
});
composer.callbackQuery(CLEAR_CONFIRM, async (ctx) => {
const removed = ctx.chat ? chatHistoryService.clear(ctx.chat.id) : 0;
await ctx.answerCallbackQuery({ text: "Контекст очищен" });
await ctx.editMessageText(`Готово: забыл ${removed} сообщений. Начинаю с чистого листа.`).catch(() => {});
});
composer.callbackQuery(CLEAR_CANCEL, async (ctx) => {
await ctx.answerCallbackQuery({ text: "Отменено" });
await ctx.editMessageText("Очистка отменена.").catch(() => {});
});
return composer;
};

View File

@@ -12,6 +12,7 @@ import type { AssistantService } from "../../services/assistant/assistant-servic
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { ChatMemberService } from "../../services/chat-member-service.js";
import type { PersonaService } from "../../services/persona-service.js";
import { collectImages, imageFileOf } from "../images.js";
import { createReplier, withTyping } from "../reply.js";
interface QuestionHandlersDeps {
@@ -21,6 +22,16 @@ interface QuestionHandlersDeps {
chatMemberService: ChatMemberService;
}
/** Прислали только картинку без текста — считаем, что спрашивают про неё. */
const IMAGE_ONLY_QUESTION = "Что на изображении?";
/** `/ask ...` в подписи к фото: grammY ищет команды только в тексте, подписи разбираем сами. */
const askInCaption = (caption: string | undefined, username: string): string | undefined => {
const command = caption?.match(new RegExp(`^/ask(?:@${username})?(?:\\s+|$)`, "i"));
return command ? caption!.slice(command[0].length).trim() : undefined;
};
const chatTitle = (ctx: Context): string => {
if (!ctx.chat) return "неизвестный чат";
@@ -56,7 +67,11 @@ export const createQuestionHandlers = ({
return chatMemberService.resolveMentions(ctx.chat!.id, { usernames, profiles });
};
const answerQuestion = async (ctx: Context, text: string) => {
const answerQuestion = async (ctx: Context, rawText: string) => {
const msg = ctx.msg!;
const hasImage = imageFileOf(msg) !== undefined || imageFileOf(msg.reply_to_message) !== undefined;
const text = rawText || (hasImage ? IMAGE_ONLY_QUESTION : "");
if (!text) {
await reply(ctx, "Задай вопрос: /ask <вопрос>");
return;
@@ -66,18 +81,23 @@ export const createQuestionHandlers = ({
const { members, unknownUsernames } = resolveMentions(ctx);
const question = {
chatId,
chatTitle: chatTitle(ctx),
askerName: displayName(ctx.from!),
text,
repliedTo: repliedToMessage(ctx),
mentionedMembers: members,
unknownUsernames,
const ask = async () => {
const question = {
chatId,
chatTitle: chatTitle(ctx),
askerName: displayName(ctx.from!),
text,
repliedTo: repliedToMessage(ctx),
mentionedMembers: members,
unknownUsernames,
images: hasImage ? await collectImages(ctx.api, msg) : [],
};
return assistantService.answer(question, personaService.getActive(chatId));
};
try {
const answer = await withTyping(ctx, () => assistantService.answer(question, personaService.getActive(chatId)));
const answer = await withTyping(ctx, ask);
await reply(ctx, answer || "Мне нечего ответить 🤷");
} catch (error) {
@@ -88,10 +108,19 @@ export const createQuestionHandlers = ({
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
composer.on("message:text", async (ctx) => {
const text = ctx.msg.text;
// Текст или подпись к фото: в личке — всегда, в группе — при упоминании бота или ответе на его сообщение.
composer.on("message", async (ctx) => {
const text = ctx.msg.text ?? ctx.msg.caption ?? "";
const hasImage = imageFileOf(ctx.msg) !== undefined;
if (text.startsWith("/")) return;
const captionQuestion = askInCaption(ctx.msg.caption, ctx.me.username);
if (captionQuestion !== undefined) {
await answerQuestion(ctx, captionQuestion);
return;
}
if (text.startsWith("/") || (!text && !hasImage)) return;
const isPrivate = ctx.chat.type === "private";
const mentioned = mentions(text, ctx.me.username);

61
src/bot/images.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { Api } from "grammy";
import type { Message } from "grammy/types";
import type { ImageAttachment } from "../types.js";
/** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
interface ImageFile {
fileId: string;
mimeType: string;
}
/** Картинка в сообщении: фото (берём самый крупный размер) или файл-изображение. */
export const imageFileOf = (msg: Message | undefined): ImageFile | undefined => {
const photo = msg?.photo?.at(-1);
if (photo) return { fileId: photo.file_id, mimeType: "image/jpeg" };
const document = msg?.document;
if (document?.mime_type?.startsWith("image/")) return { fileId: document.file_id, mimeType: document.mime_type };
return undefined;
};
/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */
const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
const { file_path: filePath, file_size: fileSize } = await api.getFile(file.fileId);
if (!filePath) throw new Error("Telegram не отдал путь к файлу");
if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`);
const response = await fetch(`https://api.telegram.org/file/bot${api.token}/${filePath}`);
if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`);
return new Uint8Array(await response.arrayBuffer());
};
/** Фото из вопроса и из сообщения, на которое ответили. Не скачалось — пропускаем, а не роняем ответ. */
export const collectImages = async (api: Api, msg: Message): Promise<ImageAttachment[]> => {
const candidates = [
{ source: "question" as const, file: imageFileOf(msg) },
{ source: "replied" as const, file: imageFileOf(msg.reply_to_message) },
];
const downloaded = await Promise.all(
candidates.map(async ({ source, file }) => {
if (!file) return undefined;
try {
return { data: await download(api, file), mimeType: file.mimeType, source };
} catch (error) {
console.error("Не удалось скачать картинку:", error);
return undefined;
}
}),
);
return downloaded.filter((image) => image !== undefined);
};

View File

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

View File

@@ -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 ? "да" : "нет"}`),
});

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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