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

View File

@@ -0,0 +1,75 @@
import type { Message, User } from "grammy/types";
import type { ChatMessage } from "../types.js";
const TELEGRAM_MESSAGE_LIMIT = 4096;
export const displayName = (user: User): string => {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
return user.username ? `${name} (@${user.username})` : name;
};
const withLabel = (label: string, body: string | undefined): string => (body ? `${label} ${body}` : label);
/** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */
export const messageText = (msg: Message): string | undefined => {
const body = msg.text ?? msg.caption;
if (msg.photo) return withLabel("[фото]", body);
if (msg.video) return withLabel("[видео]", body);
if (msg.voice) return "[голосовое сообщение]";
if (msg.video_note) return "[видеосообщение]";
if (msg.sticker) return withLabel("[стикер]", msg.sticker.emoji);
if (msg.document) return withLabel(`[файл ${msg.document.file_name ?? "без имени"}]`, body);
if (msg.poll) return withLabel("[опрос]", msg.poll.question);
return body;
};
/** В темах форума каждое сообщение формально отвечает на первое сообщение темы — это не настоящий ответ. */
const replyToMessageId = (msg: Message): number | null => {
const original = msg.reply_to_message;
if (!original || original.forum_topic_created) return null;
return original.message_id;
};
export const toChatMessage = (msg: Message): ChatMessage | undefined => {
const text = messageText(msg);
if (!text) return undefined;
return {
chatId: msg.chat.id,
messageId: msg.message_id,
date: msg.date,
author: msg.from ? displayName(msg.from) : (msg.sender_chat?.title ?? "Аноним"),
text,
replyToMessageId: replyToMessageId(msg),
};
};
/** Режет длинный ответ на части под лимит Telegram, по возможности по переносам строк. */
export const splitMessage = (text: string): string[] => {
if (text.length <= TELEGRAM_MESSAGE_LIMIT) return text ? [text] : [];
const newline = text.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
const cut = newline > TELEGRAM_MESSAGE_LIMIT / 2 ? newline : TELEGRAM_MESSAGE_LIMIT;
return [text.slice(0, cut), ...splitMessage(text.slice(cut).trimStart())];
};
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** `@username` целиком: `@boltun_bot2` не считается упоминанием `@boltun_bot`. */
const mentionPattern = (username: string): RegExp => new RegExp(`@${escapeRegExp(username)}(?![a-z0-9_])`, "gi");
export const mentions = (text: string, username: string): boolean => mentionPattern(username).test(text);
export const removeMention = (text: string, username: string): string =>
text
.replace(mentionPattern(username), "")
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+([,.!?])/g, "$1")
.trim();