Telegram-бот Boltun AI на grammY + Mastra

- Отвечает только администраторам из ADMIN_IDS
- Личности на чат: встроенные и пользовательские (/personas, /persona, /persona_add, /persona_del)
- Вопросы через /ask, упоминание, ответ боту или личку
- Сохраняет сообщения чата в SQLite; агент по просьбе читает их инструментом read_chat_messages
- Модели через OpenRouter (по умолчанию DeepSeek)
- Слои: libs, repositories, services, bot/handlers
- Dockerfile для деплоя в Dokploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 18:31:44 +02:00
commit 99c4660da9
32 changed files with 3173 additions and 0 deletions

26
src/bot/commands.ts Normal file
View File

@@ -0,0 +1,26 @@
export const BOT_COMMANDS = [
{ command: "ask", description: "Задать вопрос боту" },
{ command: "personas", description: "Список личностей и выбор" },
{ command: "persona", description: "Выбрать личность: /persona <id>" },
{ command: "persona_add", description: "Добавить личность: /persona_add <id> <описание>" },
{ command: "persona_del", description: "Удалить пользовательскую личность: /persona_del <id>" },
{ command: "help", description: "Справка" },
];
export const helpText = (username: string) => `Я отвечаю только администраторам.
Как спросить:
• /ask <вопрос>
• упомяни меня (@${username}) или ответь на моё сообщение
• в личке — просто напиши
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
Личности:
• /personas — список и выбор кнопками
• /persona <id> — выбрать
• /persona_add <id> <описание характера> — добавить или изменить пользовательскую
• /persona_del <id> — удалить пользовательскую
/whoami — узнать свой Telegram ID (в личке).`;

37
src/bot/create-bot.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Bot } from "grammy";
import type { AssistantService } from "../services/assistant/assistant-service.js";
import type { ChatHistoryService } from "../services/chat-history-service.js";
import type { PersonaService } from "../services/persona-service.js";
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
import { createHistoryHandlers } from "./handlers/history-handlers.js";
import { createPersonaHandlers } from "./handlers/persona-handlers.js";
import { createQuestionHandlers } from "./handlers/question-handlers.js";
interface BotDeps {
token: string;
adminIds: ReadonlySet<number>;
personaService: PersonaService;
chatHistoryService: ChatHistoryService;
assistantService: AssistantService;
}
export const createBot = ({ token, adminIds, personaService, chatHistoryService, assistantService }: BotDeps): Bot => {
const bot = new Bot(token);
bot.use(createHistoryHandlers(chatHistoryService));
bot.use(createPublicHandlers());
// Всё, что ниже, доступно только администраторам; остальных бот молча игнорирует.
const admin = bot.filter((ctx) => ctx.from !== undefined && adminIds.has(ctx.from.id));
admin.use(createHelpHandlers());
admin.use(createPersonaHandlers(personaService));
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService }));
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
bot.on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));
bot.catch((err) => console.error(`Ошибка при обработке апдейта ${err.ctx.update.update_id}:`, err.error));
return bot;
};

View File

@@ -0,0 +1,21 @@
import { Composer } from "grammy";
import { helpText } from "../commands.js";
/** Доступно всем: узнать свой ID, чтобы прописать его в ADMIN_IDS. */
export const createPublicHandlers = () => {
const composer = new Composer();
composer.command("whoami", async (ctx) => {
if (ctx.chat.type === "private") await ctx.reply(`Твой Telegram ID: ${ctx.from!.id}`);
});
return composer;
};
export const createHelpHandlers = () => {
const composer = new Composer();
composer.command(["start", "help"], (ctx) => ctx.reply(helpText(ctx.me.username)));
return composer;
};

View File

@@ -0,0 +1,20 @@
import { Composer } from "grammy";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import { createMessageRecorder } from "../reply.js";
/** Запоминает сообщения всех участников — из них бот читает контекст по просьбе. */
export const createHistoryHandlers = (chatHistoryService: ChatHistoryService) => {
const composer = new Composer();
const recordMessage = createMessageRecorder(chatHistoryService);
composer.on("message", async (ctx, next) => {
recordMessage(ctx.msg);
await next();
});
// Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно.
composer.on("edited_message", (ctx) => recordMessage(ctx.msg));
return composer;
};

View File

@@ -0,0 +1,123 @@
import { Composer, InlineKeyboard } from "grammy";
import type { PersonaService } from "../../services/persona-service.js";
import type { Persona } from "../../types.js";
const PROMPT_PREVIEW_LENGTH = 120;
const CALLBACK_PREFIX = "persona:";
const CALLBACK_PATTERN = new RegExp(`^${CALLBACK_PREFIX}(.+)$`);
const ADD_USAGE =
"Формат: /persona_add <id> <описание характера>\n" +
"id — латиница, цифры, _ или - (до 32 символов).\n" +
"Пример: /persona_add gopnik Ты гопник с района, говоришь с понятиями, но помогаешь.";
const DEL_USAGE = "Формат: /persona_del <id>. Список: /personas";
const preview = (prompt: string): string =>
prompt.length > PROMPT_PREVIEW_LENGTH ? `${prompt.slice(0, PROMPT_PREVIEW_LENGTH)}…` : prompt;
const buildKeyboard = (personas: Persona[], activeId: string): InlineKeyboard =>
personas.reduce(
(kb, p) => kb.text(`${p.id === activeId ? "✅ " : ""}${p.name}`, `${CALLBACK_PREFIX}${p.id}`).row(),
new InlineKeyboard(),
);
const buildListText = (personas: Persona[], activeId: string): string => {
const lines = personas.map((p) => {
const mark = p.id === activeId ? "✅" : "•";
const kind = p.builtin ? "" : " (пользовательская)";
return `${mark} ${p.name} [${p.id}]${kind}\n ${preview(p.prompt)}`;
});
return `Личности бота:\n\n${lines.join("\n\n")}`;
};
/** Команды управления личностями. Подключать только за фильтром администратора. */
export const createPersonaHandlers = (personaService: PersonaService) => {
const composer = new Composer();
/** Список личностей с кнопками выбора для конкретного чата. */
const personaMenu = (chatId: number) => {
const personas = personaService.list();
const activeId = personaService.getActive(chatId).id;
return { text: buildListText(personas, activeId), reply_markup: buildKeyboard(personas, activeId) };
};
composer.command("personas", async (ctx) => {
const { text, reply_markup } = personaMenu(ctx.chat.id);
await ctx.reply(text, { reply_markup });
});
composer.command("persona", async (ctx) => {
const id = ctx.match.trim();
if (!id) {
const { text, reply_markup } = personaMenu(ctx.chat.id);
await ctx.reply(text, { reply_markup });
return;
}
const persona = personaService.select(ctx.chat.id, id);
await ctx.reply(persona ? `Теперь я — ${persona.name}.` : `Личность «${id}» не найдена. Список: /personas`);
});
composer.callbackQuery(CALLBACK_PATTERN, async (ctx) => {
const chatId = ctx.chat?.id;
const persona = chatId === undefined ? undefined : personaService.select(chatId, ctx.match[1]!);
if (!persona) {
await ctx.answerCallbackQuery({ text: "Личность не найдена" });
return;
}
const { text, reply_markup } = personaMenu(chatId!);
await ctx.answerCallbackQuery({ text: `Теперь я — ${persona.name}` });
await ctx.editMessageText(text, { reply_markup }).catch(() => {});
});
composer.command("persona_add", async (ctx) => {
const args = ctx.match.trim();
const [rawId = ""] = args.split(/\s+/);
const result = personaService.saveCustom(rawId, args.slice(rawId.length));
const errors = {
invalid: ADD_USAGE,
builtin: `«${result.id}» — встроенная личность, её нельзя изменить. Выбери другой id.`,
};
await ctx.reply(
result.ok
? `${result.updated ? "Обновил" : "Добавил"} личность «${result.id}». Включить: /persona ${result.id}`
: errors[result.reason],
);
});
composer.command("persona_del", async (ctx) => {
const id = ctx.match.trim();
if (!id) {
await ctx.reply(DEL_USAGE);
return;
}
const result = personaService.removeCustom(id);
const errors = {
not_found: `Личность «${result.id}» не найдена. Список: /personas`,
builtin: "Встроенные личности удалить нельзя.",
};
await ctx.reply(result.ok ? `Удалил личность «${result.id}».` : errors[result.reason]);
});
return composer;
};

View File

@@ -0,0 +1,75 @@
import { Composer, type Context } from "grammy";
import { displayName, mentions, removeMention, toChatMessage } from "../../libs/telegram-format.js";
import type { AssistantService } from "../../services/assistant/assistant-service.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { PersonaService } from "../../services/persona-service.js";
import { createReplier, withTyping } from "../reply.js";
interface QuestionHandlersDeps {
assistantService: AssistantService;
personaService: PersonaService;
chatHistoryService: ChatHistoryService;
}
const chatTitle = (ctx: Context): string => {
if (!ctx.chat) return "неизвестный чат";
return ctx.chat.type === "private" ? "личные сообщения" : ctx.chat.title;
};
/** Вопросы боту: /ask, упоминание, ответ на сообщение бота, любое сообщение в личке. Только за фильтром админа. */
export const createQuestionHandlers = ({ assistantService, personaService, chatHistoryService }: QuestionHandlersDeps) => {
const composer = new Composer();
const reply = createReplier(chatHistoryService);
const repliedToMessage = (ctx: Context) => {
const original = ctx.msg?.reply_to_message;
if (!original) return undefined;
return chatHistoryService.find(original.chat.id, original.message_id) ?? toChatMessage(original);
};
const answerQuestion = async (ctx: Context, text: string) => {
if (!text) {
await reply(ctx, "Задай вопрос: /ask <вопрос>");
return;
}
const chatId = ctx.chat!.id;
const question = {
chatId,
chatTitle: chatTitle(ctx),
askerName: displayName(ctx.from!),
text,
repliedTo: repliedToMessage(ctx),
};
try {
const answer = await withTyping(ctx, () => assistantService.answer(question, personaService.getActive(chatId)));
await reply(ctx, answer || "Мне нечего ответить 🤷");
} catch (error) {
console.error("Ошибка генерации ответа:", error);
await reply(ctx, "Не получилось получить ответ от модели, попробуй ещё раз позже.");
}
};
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
composer.on("message:text", async (ctx) => {
const text = ctx.msg.text;
if (text.startsWith("/")) return;
const isPrivate = ctx.chat.type === "private";
const mentioned = mentions(text, ctx.me.username);
const repliedToBot = ctx.msg.reply_to_message?.from?.id === ctx.me.id;
if (isPrivate || mentioned || repliedToBot) await answerQuestion(ctx, removeMention(text, ctx.me.username));
});
return composer;
};

44
src/bot/reply.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { Context } from "grammy";
import type { Message } from "grammy/types";
import { splitMessage, toChatMessage } from "../libs/telegram-format.js";
import type { ChatHistoryService } from "../services/chat-history-service.js";
const TYPING_INTERVAL_MS = 4500;
/** Сохраняет сообщение в историю чата, если в нём есть что сохранять. */
export const createMessageRecorder = (chatHistoryService: ChatHistoryService) => (msg: Message) => {
const chatMessage = toChatMessage(msg);
if (chatMessage) chatHistoryService.record(chatMessage);
};
/** Отвечает на текущее сообщение (длинный текст — несколькими частями) и сохраняет ответ в историю. */
export const createReplier = (chatHistoryService: ChatHistoryService) => {
const recordMessage = createMessageRecorder(chatHistoryService);
return (ctx: Context, text: string): Promise<void> =>
splitMessage(text).reduce(async (previous, chunk) => {
await previous;
const sent = await ctx.reply(chunk, {
reply_parameters: { message_id: ctx.msgId!, allow_sending_without_reply: true },
});
recordMessage(sent);
}, Promise.resolve());
};
/** Пока идёт задача, показываем «печатает…» (статус в Telegram живёт ~5 секунд). */
export const withTyping = async <T>(ctx: Context, task: () => Promise<T>): Promise<T> => {
const sendTyping = () => ctx.replyWithChatAction("typing").catch(() => {});
void sendTyping();
const timer = setInterval(sendTyping, TYPING_INTERVAL_MS);
try {
return await task();
} finally {
clearInterval(timer);
}
};