Упрощение: общие хелперы, команды и упоминания по entities, фото только для моделей со зрением

- Одна formatUtc и один displayName(profile) вместо копий в разных слоях
- commandOf/mentionsUser/textWithoutMention работают по entities для текста и подписей;
  убраны askInCaption, regex-поиск упоминаний и отдельный composer.command("ask")
- Фото скачиваются только если модель их видит; пустой вопрос с картинкой формулирует промпт
- Поддержка фото задаётся явно для моделей по умолчанию, для своей AI_MODEL — через AI_IMAGE_INPUT
- Активность участников считается по сохранённой истории, а не отдельными счётчиками;
  один хендлер для message и edited_message, упоминания не пишутся дважды
- SQLite: synchronous = NORMAL для WAL
- Кнопки не-админов отклоняются через bot.drop(isAdmin)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 20:10:56 +02:00
parent f8273e8a23
commit e812f1b1e2
21 changed files with 269 additions and 266 deletions

View File

@@ -13,7 +13,7 @@ OPENROUTER_API_KEY=
# Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>,
# например deepseek/deepseek-v4-pro или openrouter/google/gemini-3.8-flash
# AI_MODEL=
# Необязательно: умеет ли модель смотреть фото (по умолчанию — да для gemini/vision-моделей)
# Необязательно: умеет ли модель смотреть фото. Для своей AI_MODEL по умолчанию false — включи, если модель видит картинки
# AI_IMAGE_INPUT=true
# Путь к SQLite-базе и сколько сообщений на чат хранить

View File

@@ -44,7 +44,7 @@ pnpm dev
| `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` в названии |
| `AI_IMAGE_INPUT` | нет | `true`/`false` — отправлять ли модели фото. Для моделей по умолчанию выставляется само (Gemini — да, DeepSeek — нет); для своей `AI_MODEL` по умолчанию `false` |
\* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте.
| `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) |

View File

@@ -1,4 +1,4 @@
import { Bot } from "grammy";
import { Bot, type Context } from "grammy";
import type { AssistantService } from "../services/assistant/assistant-service.js";
import type { ChatHistoryService } from "../services/chat-history-service.js";
import type { ChatMemberService } from "../services/chat-member-service.js";
@@ -31,8 +31,10 @@ export const createBot = ({
bot.use(createHistoryHandlers({ chatHistoryService, chatMemberService }));
bot.use(createPublicHandlers());
const isAdmin = (ctx: Context) => ctx.from !== undefined && adminIds.has(ctx.from.id);
// Всё, что ниже, доступно только администраторам; остальных бот молча игнорирует.
const admin = bot.filter((ctx) => ctx.from !== undefined && adminIds.has(ctx.from.id));
const admin = bot.filter(isAdmin);
admin.use(createHelpHandlers());
admin.use(createPersonaHandlers(personaService));
@@ -40,7 +42,7 @@ export const createBot = ({
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
bot.on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));
bot.drop(isAdmin).on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));
bot.catch((err) => console.error(`Ошибка при обработке апдейта ${err.ctx.update.update_id}:`, err.error));

View File

@@ -1,4 +1,5 @@
import { Composer, InlineKeyboard, type Context } from "grammy";
import { formatUtc } from "../../libs/time.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { ChatMemberService } from "../../services/chat-member-service.js";
@@ -11,9 +12,6 @@ interface ContextHandlersDeps {
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 "Это личка — я вижу все сообщения.";
@@ -37,7 +35,7 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService }:
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 since = oldest === null ? "" : `, с ${formatUtc(oldest)}`;
const lines = [
`Помню сообщений: ${count} из ${limit} возможных${since}.`,

View File

@@ -1,6 +1,5 @@
import { Composer } from "grammy";
import type { Message } from "grammy/types";
import { mentionedUsers, toMemberProfile } from "../../libs/telegram-format.js";
import { mentionedProfiles, toMemberProfile } from "../../libs/telegram-format.js";
import type { ChatHistoryService } from "../../services/chat-history-service.js";
import type { ChatMemberService } from "../../services/chat-member-service.js";
import { createMessageRecorder } from "../reply.js";
@@ -16,27 +15,15 @@ export const createHistoryHandlers = ({ chatHistoryService, chatMemberService }:
const recordMessage = createMessageRecorder(chatHistoryService);
/** Имена людей, упомянутых по имени-ссылке, — чтобы потом узнавать их и без ника. */
const rememberMentioned = (msg: Message) =>
mentionedUsers(msg)
.filter((user) => !user.is_bot)
.forEach((user) => chatMemberService.remember(msg.chat.id, toMemberProfile(user)));
composer.on(["message", "edited_message"], async (ctx, next) => {
const msg = ctx.msg;
const author = ctx.from && !ctx.from.is_bot ? [toMemberProfile(ctx.from)] : [];
composer.on("message", async (ctx, next) => {
recordMessage(ctx.msg);
rememberMentioned(ctx.msg);
if (ctx.from && !ctx.from.is_bot) chatMemberService.recordActivity(ctx.chat.id, toMemberProfile(ctx.from), ctx.msg.date);
await next();
});
recordMessage(msg);
[...author, ...mentionedProfiles(msg)].forEach((profile) => chatMemberService.remember(msg.chat.id, profile));
// Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно.
composer.on("edited_message", (ctx) => {
recordMessage(ctx.msg);
rememberMentioned(ctx.msg);
if (ctx.from && !ctx.from.is_bot) chatMemberService.remember(ctx.chat.id, toMemberProfile(ctx.from));
if (ctx.message) await next();
});
return composer;

View File

@@ -1,10 +1,12 @@
import { Composer, type Context } from "grammy";
import type { Message } from "grammy/types";
import {
commandOf,
displayName,
mentionedProfiles,
mentionedUsernames,
mentionedUsers,
mentions,
removeMention,
mentionsUser,
textWithoutMention,
toChatMessage,
toMemberProfile,
} from "../../libs/telegram-format.js";
@@ -12,7 +14,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 { downloadImages, imageRefsOf } from "../images.js";
import { createReplier, withTyping } from "../reply.js";
interface QuestionHandlersDeps {
@@ -22,16 +24,6 @@ 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 "неизвестный чат";
@@ -49,8 +41,8 @@ export const createQuestionHandlers = ({
const reply = createReplier(chatHistoryService);
const repliedToMessage = (ctx: Context) => {
const original = ctx.msg?.reply_to_message;
const repliedToMessage = (msg: Message) => {
const original = msg.reply_to_message;
if (!original) return undefined;
@@ -58,42 +50,38 @@ export const createQuestionHandlers = ({
};
/** Кого упомянули в вопросе (кроме самого бота) — подсказываем модели, кто эти люди. */
const resolveMentions = (ctx: Context) => {
const resolveMentions = (msg: Message, botUsername: string) =>
chatMemberService.resolveMentions(msg.chat.id, {
usernames: mentionedUsernames(msg).filter((u) => u.toLowerCase() !== botUsername.toLowerCase()),
userIds: mentionedProfiles(msg).map((profile) => profile.userId),
});
const answerQuestion = async (ctx: Context, text: string) => {
const msg = ctx.msg!;
const imageRefs = imageRefsOf(msg);
const usernames = mentionedUsernames(msg).filter((u) => u.toLowerCase() !== ctx.me.username.toLowerCase());
const profiles = mentionedUsers(msg).filter((user) => !user.is_bot).map(toMemberProfile);
return chatMemberService.resolveMentions(ctx.chat!.id, { usernames, profiles });
};
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 && imageRefs.length === 0) {
await reply(ctx, "Задай вопрос: /ask <вопрос>");
return;
}
const chatId = ctx.chat!.id;
const { members, unknownUsernames } = resolveMentions(ctx);
const ask = async () => {
const images = assistantService.canSeeImages ? await downloadImages(ctx.api, imageRefs) : [];
const { members, unknownUsernames } = resolveMentions(msg, ctx.me.username);
const question = {
chatId,
chatId: msg.chat.id,
chatTitle: chatTitle(ctx),
askerName: displayName(ctx.from!),
askerName: displayName(toMemberProfile(ctx.from!)),
text,
repliedTo: repliedToMessage(ctx),
repliedTo: repliedToMessage(msg),
mentionedMembers: members,
unknownUsernames,
images: hasImage ? await collectImages(ctx.api, msg) : [],
imageSources: imageRefs.map((ref) => ref.source),
images,
};
return assistantService.answer(question, personaService.getActive(chatId));
return assistantService.answer(question, personaService.getActive(msg.chat.id));
};
try {
@@ -106,27 +94,27 @@ export const createQuestionHandlers = ({
}
};
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
// Текст или подпись к фото: в личке — всегда, в группе — при упоминании бота или ответе на его сообщение.
// Текст или подпись к фото. /ask — где угодно; без команды: в личке — всегда,
// в группе — при упоминании бота или ответе на его сообщение.
composer.on("message", async (ctx) => {
const text = ctx.msg.text ?? ctx.msg.caption ?? "";
const hasImage = imageFileOf(ctx.msg) !== undefined;
const msg = ctx.msg;
const command = commandOf(msg, ctx.me.username);
const captionQuestion = askInCaption(ctx.msg.caption, ctx.me.username);
if (captionQuestion !== undefined) {
await answerQuestion(ctx, captionQuestion);
if (command) {
if (command.name === "ask") await answerQuestion(ctx, command.args);
return;
}
if (text.startsWith("/") || (!text && !hasImage)) return;
const text = textWithoutMention(msg, ctx.me.username);
// Стикеры, голосовые и прочее без текста и картинки — не вопрос.
if (!text && imageRefsOf(msg).length === 0) 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;
const mentioned = mentionsUser(msg, ctx.me.username);
const repliedToBot = msg.reply_to_message?.from?.id === ctx.me.id;
if (isPrivate || mentioned || repliedToBot) await answerQuestion(ctx, removeMention(text, ctx.me.username));
if (isPrivate || mentioned || repliedToBot) await answerQuestion(ctx, text);
});
return composer;

View File

@@ -1,17 +1,18 @@
import type { Api } from "grammy";
import type { Message } from "grammy/types";
import type { ImageAttachment } from "../types.js";
import type { ImageAttachment, ImageSource } from "../types.js";
/** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
interface ImageFile {
export interface ImageRef {
source: ImageSource;
fileId: string;
mimeType: string;
}
/** Картинка в сообщении: фото (берём самый крупный размер) или файл-изображение. */
export const imageFileOf = (msg: Message | undefined): ImageFile | undefined => {
const imageFileOf = (msg: Message | undefined): Omit<ImageRef, "source"> | undefined => {
const photo = msg?.photo?.at(-1);
if (photo) return { fileId: photo.file_id, mimeType: "image/jpeg" };
@@ -23,9 +24,16 @@ export const imageFileOf = (msg: Message | undefined): ImageFile | undefined =>
return undefined;
};
/** Картинки вопроса: из самого сообщения и из того, на которое ответили. Без скачивания. */
export const imageRefsOf = (msg: Message): ImageRef[] =>
[
{ source: "question" as const, file: imageFileOf(msg) },
{ source: "replied" as const, file: imageFileOf(msg.reply_to_message) },
].flatMap(({ source, file }) => (file ? [{ source, ...file }] : []));
/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */
const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
const { file_path: filePath, file_size: fileSize } = await api.getFile(file.fileId);
const download = async (api: Api, ref: ImageRef): Promise<ImageAttachment> => {
const { file_path: filePath, file_size: fileSize } = await api.getFile(ref.fileId);
if (!filePath) throw new Error("Telegram не отдал путь к файлу");
if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`);
@@ -34,28 +42,19 @@ const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`);
return new Uint8Array(await response.arrayBuffer());
return { data: new Uint8Array(await response.arrayBuffer()), mimeType: ref.mimeType, source: ref.source };
};
/** Фото из вопроса и из сообщения, на которое ответили. Не скачалось — пропускаем, а не роняем ответ. */
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) {
/** Скачивает картинки параллельно. Не скачалась — пропускаем, а не роняем ответ. */
export const downloadImages = async (api: Api, refs: ImageRef[]): Promise<ImageAttachment[]> => {
const images = await Promise.all(
refs.map((ref) =>
download(api, ref).catch((error: unknown) => {
console.error("Не удалось скачать картинку:", error);
return undefined;
}
}),
),
);
return downloaded.filter((image) => image !== undefined);
return images.filter((image) => image !== undefined);
};

View File

@@ -1,5 +1,8 @@
/** Значение переменной окружения без пробелов; undefined — если не задана или пустая. */
const envValue = (name: string): string | undefined => process.env[name]?.trim() || undefined;
const required = (name: string): string => {
const value = process.env[name]?.trim();
const value = envValue(name);
if (!value) throw new Error(`Не задана переменная окружения ${name}`);
@@ -7,7 +10,7 @@ const required = (name: string): string => {
};
const positiveInt = (name: string, fallback: number): number => {
const raw = process.env[name]?.trim();
const raw = envValue(name);
if (!raw) return fallback;
@@ -40,12 +43,20 @@ const PROVIDER_API_KEYS: Record<string, string> = {
openrouter: "OPENROUTER_API_KEY",
};
/** Есть ключ OpenRouter — Gemini 3.8 Flash (умеет фото), иначе DeepSeek напрямую (только текст). */
const defaultModel = (): string =>
process.env.OPENROUTER_API_KEY?.trim() ? "openrouter/google/gemini-3.8-flash" : "deepseek/deepseek-flash";
interface ModelChoice {
model: string;
/** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */
imageInput: boolean;
}
/** Есть ключ OpenRouter — Gemini 3.8 Flash (видит фото), иначе DeepSeek напрямую (только текст). */
const defaultModel = (): ModelChoice =>
envValue("OPENROUTER_API_KEY")
? { model: "openrouter/google/gemini-3.8-flash", imageInput: true }
: { model: "deepseek/deepseek-flash", imageInput: false };
const parseBool = (name: string): boolean | undefined => {
const raw = process.env[name]?.trim().toLowerCase();
const raw = envValue(name)?.toLowerCase();
if (!raw) return undefined;
if (["1", "true", "yes"].includes(raw)) return true;
@@ -54,40 +65,34 @@ const parseBool = (name: string): boolean | undefined => {
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_IMAGE_INPUT=true) или по умолчанию + проверка ключа провайдера. */
const parseModel = (): ModelChoice => {
const customModel = envValue("AI_MODEL");
const hasAnyKey = Object.values(PROVIDER_API_KEYS).some((name) => envValue(name));
/** Модель из AI_MODEL (или по умолчанию) + проверка, что ключ её провайдера задан. */
const parseModel = (): string => {
const hasAnyKey = Object.values(PROVIDER_API_KEYS).some((name) => process.env[name]?.trim());
if (!process.env.AI_MODEL?.trim() && !hasAnyKey) {
throw new Error("Задай ключ модели: DEEPSEEK_API_KEY (DeepSeek напрямую) или OPENROUTER_API_KEY");
if (!customModel && !hasAnyKey) {
throw new Error("Задай ключ модели: OPENROUTER_API_KEY или DEEPSEEK_API_KEY (DeepSeek напрямую)");
}
const model = process.env.AI_MODEL?.trim() || defaultModel();
const fallback = defaultModel();
const model = customModel ?? fallback.model;
const imageInput = parseBool("AI_IMAGE_INPUT") ?? (customModel ? false : fallback.imageInput);
const [provider = ""] = model.split("/");
const keyName = PROVIDER_API_KEYS[provider];
if (keyName) required(keyName);
return model;
return { model, imageInput };
};
/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */
export const DB_PATH = process.env.DB_PATH?.trim() || "data/bot.db";
export const DB_PATH = envValue("DB_PATH") ?? "data/bot.db";
export const loadBotConfig = () => {
const aiModel = parseModel();
return {
export const loadBotConfig = () => ({
botToken: required("BOT_TOKEN"),
adminIds: parseAdminIds(required("ADMIN_IDS")),
aiModel,
/** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */
aiImageInput: parseImageInput(aiModel),
ai: parseModel(),
/** Сколько последних сообщений каждого чата хранить в базе. */
historyLimit: positiveInt("HISTORY_LIMIT", 2000),
};
};
});

View File

@@ -30,8 +30,8 @@ const chatHistoryService = createChatHistoryService({
const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) });
const assistantService = createAssistantService({
model: config.aiModel,
imageInput: config.aiImageInput,
model: config.ai.model,
imageInput: config.ai.imageInput,
chatHistoryService,
chatMemberService,
});
@@ -59,5 +59,5 @@ await bot.api.setMyCommands(BOT_COMMANDS);
await bot.start({
allowed_updates: ["message", "edited_message", "callback_query"],
onStart: (me) =>
console.log(`Бот @${me.username} запущен, модель ${config.aiModel}, фото: ${config.aiImageInput ? "да" : "нет"}`),
console.log(`Бот @${me.username} запущен, модель ${config.ai.model}, фото: ${config.ai.imageInput ? "да" : "нет"}`),
});

View File

@@ -33,8 +33,6 @@ const SCHEMA = `
username TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
message_count INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_id, user_id)
);
`;
@@ -55,6 +53,8 @@ export const createDb = (path: string): Db => {
const db = new Database(path);
db.pragma("journal_mode = WAL");
// Каждое сообщение чата — запись в базу; с WAL NORMAL надёжен и не делает fsync на каждый коммит.
db.pragma("synchronous = NORMAL");
db.exec(SCHEMA);
migrate(db);

View File

@@ -3,6 +3,6 @@ import { readFileSync } from "node:fs";
/** `prompts/` в корне проекта; путь одинаково работает из `src/libs` (tsx) и из `dist/libs` (сборка). */
const PROMPTS_DIR = new URL("../../prompts/", import.meta.url);
/** Читает текстовый промпт, например `readPrompt("personas/pirate.md")`. */
/** Читает текстовый промпт, например `readPrompt("personas/boltun.md")`. */
export const readPrompt = (relativePath: string): string =>
readFileSync(new URL(relativePath, PROMPTS_DIR), "utf8").trim();

View File

@@ -10,27 +10,34 @@ export const toMemberProfile = (user: User): MemberProfile => ({
lastName: user.last_name ?? null,
});
export const displayName = (user: User): string => {
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
/** «Имя Фамилия (@ник)» — одинаково для авторов сообщений, спрашивающего и списка участников. */
export const displayName = (profile: MemberProfile): string => {
const name = [profile.firstName, profile.lastName].filter(Boolean).join(" ");
return user.username ? `${name} (@${user.username})` : name;
return profile.username ? `${name} (@${profile.username})` : name;
};
const withLabel = (label: string, body: string | undefined): string => (body ? `${label} ${body}` : label);
/** Текст сообщения или подпись к медиа — там, где Telegram держит текст и его entities. */
const messageBody = (msg: Message): { text: string; entities: MessageEntity[] } => ({
text: msg.text ?? msg.caption ?? "",
entities: msg.entities ?? msg.caption_entities ?? [],
});
const withLabel = (label: string, body: string): string => (body ? `${label} ${body}` : label);
/** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */
export const messageText = (msg: Message): string | undefined => {
const body = msg.text ?? msg.caption;
const { text } = messageBody(msg);
if (msg.photo) return withLabel("[фото]", body);
if (msg.video) return withLabel("[видео]", body);
if (msg.photo) return withLabel("[фото]", text);
if (msg.video) return withLabel("[видео]", text);
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.sticker) return withLabel("[стикер]", msg.sticker.emoji ?? "");
if (msg.document) return withLabel(`[файл ${msg.document.file_name ?? "без имени"}]`, text);
if (msg.poll) return withLabel("[опрос]", msg.poll.question);
return body;
return text || undefined;
};
/** В темах форума каждое сообщение формально отвечает на первое сообщение темы — это не настоящий ответ. */
@@ -52,31 +59,75 @@ export const toChatMessage = (msg: Message): ChatMessage | undefined => {
messageId: msg.message_id,
userId: msg.from?.id ?? null,
date: msg.date,
author: msg.from ? displayName(msg.from) : (msg.sender_chat?.title ?? "Аноним"),
author: msg.from ? displayName(toMemberProfile(msg.from)) : (msg.sender_chat?.title ?? "Аноним"),
text,
replyToMessageId: replyToMessageId(msg),
};
};
const messageEntities = (msg: Message): { text: string; entities: MessageEntity[] } => ({
text: msg.text ?? msg.caption ?? "",
entities: msg.entities ?? msg.caption_entities ?? [],
});
const entityText = (text: string, entity: MessageEntity): string =>
text.slice(entity.offset, entity.offset + entity.length);
const sameUsername = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
/** Ники из упоминаний `@username` (без собаки), без повторов. */
export const mentionedUsernames = (msg: Message): string[] => {
const { text, entities } = messageEntities(msg);
const { text, entities } = messageBody(msg);
const usernames = entities
.filter((entity) => entity.type === "mention")
.map((entity) => text.slice(entity.offset + 1, entity.offset + entity.length));
const usernames = entities.filter((e) => e.type === "mention").map((e) => entityText(text, e).slice(1));
return [...new Set(usernames)];
};
/** Пользователи, упомянутые по имени-ссылке — так Telegram упоминает людей без ника. */
export const mentionedUsers = (msg: Message): User[] =>
messageEntities(msg).entities.flatMap((entity) => (entity.type === "text_mention" ? [entity.user] : []));
/** Люди (не боты), упомянутые по имени-ссылке — так Telegram упоминает тех, у кого нет ника. */
export const mentionedProfiles = (msg: Message): MemberProfile[] =>
messageBody(msg).entities.flatMap((e) =>
e.type === "text_mention" && !e.user.is_bot ? [toMemberProfile(e.user)] : [],
);
export const mentionsUser = (msg: Message, username: string): boolean =>
mentionedUsernames(msg).some((u) => sameUsername(u, username));
/** Текст сообщения без упоминаний `@username` — вырезаем по entities, а не регуляркой. */
export const textWithoutMention = (msg: Message, username: string): string => {
const { text, entities } = messageBody(msg);
const mentions = entities
.filter((e) => e.type === "mention" && sameUsername(entityText(text, e).slice(1), username))
.sort((a, b) => b.offset - a.offset);
const cut = mentions.reduce((rest, e) => rest.slice(0, e.offset) + rest.slice(e.offset + e.length), text);
return cut
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+([,.!?])/g, "$1")
.trim();
};
export interface BotCommand {
name: string;
/** Текст после команды. */
args: string;
}
/**
* Команда в начале текста или подписи к медиа (`/ask@bot вопрос`).
* grammY `command()` подписи не смотрит, поэтому разбираем entity сами.
* undefined — если команды нет или она адресована другому боту.
*/
export const commandOf = (msg: Message, botUsername: string): BotCommand | undefined => {
const { text, entities } = messageBody(msg);
const entity = entities.find((e) => e.type === "bot_command" && e.offset === 0);
if (!entity) return undefined;
const [name = "", target] = entityText(text, entity).slice(1).split("@");
if (target && !sameUsername(target, botUsername)) return undefined;
return { name: name.toLowerCase(), args: text.slice(entity.length).trim() };
};
/** Режет длинный ответ на части под лимит Telegram, по возможности по переносам строк. */
export const splitMessage = (text: string): string[] => {
@@ -87,17 +138,3 @@ export const splitMessage = (text: string): string[] => {
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();

3
src/libs/time.ts Normal file
View File

@@ -0,0 +1,3 @@
/** Unix-время (секунды, как в Telegram) → «2026-09-24 18:00 UTC». */
export const formatUtc = (unixSeconds: number): string =>
`${new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC`;

View File

@@ -2,7 +2,6 @@ import type { Db } from "../libs/db.js";
import type { ChatMember, MemberProfile } from "../types.js";
interface ChatMemberRow {
chat_id: number;
user_id: number;
username: string | null;
first_name: string;
@@ -12,7 +11,6 @@ interface ChatMemberRow {
}
const toMember = (row: ChatMemberRow): ChatMember => ({
chatId: row.chat_id,
userId: row.user_id,
username: row.username,
firstName: row.first_name,
@@ -24,37 +22,35 @@ const toMember = (row: ChatMemberRow): ChatMember => ({
export const createChatMemberRepository = (db: Db) => {
const statements = {
upsert: db.prepare(`
INSERT INTO chat_members (chat_id, user_id, username, first_name, last_name, message_count, last_seen)
VALUES (@chatId, @userId, @username, @firstName, @lastName, @messages, @seenAt)
INSERT INTO chat_members (chat_id, user_id, username, first_name, last_name)
VALUES (@chatId, @userId, @username, @firstName, @lastName)
ON CONFLICT(chat_id, user_id) DO UPDATE SET
username = excluded.username,
first_name = excluded.first_name,
last_name = excluded.last_name,
message_count = message_count + excluded.message_count,
last_seen = MAX(last_seen, excluded.last_seen)
last_name = excluded.last_name
`),
// Активность считаем по сохранённой истории: после /clear и обрезки лимитом цифры остаются честными.
listByChat: db.prepare(`
SELECT m.user_id, m.username, m.first_name, m.last_name,
COUNT(msg.message_id) AS message_count,
COALESCE(MAX(msg.date), 0) AS last_seen
FROM chat_members m
LEFT JOIN messages msg ON msg.chat_id = m.chat_id AND msg.user_id = m.user_id
WHERE m.chat_id = ?
GROUP BY m.user_id
ORDER BY last_seen DESC
`),
listByChat: db.prepare("SELECT * FROM chat_members WHERE chat_id = ? ORDER BY last_seen DESC"),
findById: db.prepare("SELECT * FROM chat_members WHERE chat_id = ? AND user_id = ?"),
};
/**
* Сохраняет актуальные имя и ник участника.
* `messages` добавляется к счётчику сообщений, `seenAt` сдвигает время последней активности вперёд.
*/
const upsert = (chatId: number, profile: MemberProfile, activity: { messages: number; seenAt: number }): void => {
statements.upsert.run({ chatId, ...profile, ...activity });
/** Сохраняет актуальные имя, фамилию и ник участника. */
const upsert = (chatId: number, profile: MemberProfile): void => {
statements.upsert.run({ chatId, ...profile });
};
const listByChat = (chatId: number): ChatMember[] =>
(statements.listByChat.all(chatId) as ChatMemberRow[]).map(toMember);
const findById = (chatId: number, userId: number): ChatMember | undefined => {
const row = statements.findById.get(chatId, userId) as ChatMemberRow | undefined;
return row && toMember(row);
};
return { upsert, listByChat, findById };
return { upsert, listByChat };
};
export type ChatMemberRepository = ReturnType<typeof createChatMemberRepository>;

View File

@@ -22,14 +22,12 @@ interface AssistantServiceDeps {
const MAX_AGENT_STEPS = 6;
/** Сообщение пользователя для модели: текст вопроса и, если модель умеет, картинки. */
const buildUserMessage = (question: Question, imageInput: boolean) => ({
/** Сообщение пользователя для модели: текст вопроса и скачанные картинки. */
const buildUserMessage = (question: Question) => ({
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 }))
: []),
{ type: "text" as const, text: buildPrompt(question) },
...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
],
});
@@ -59,7 +57,7 @@ export const createAssistantService = ({
requestContext.set("chatId", question.chatId);
const result = await agent.generate([buildUserMessage(question, imageInput)], {
const result = await agent.generate([buildUserMessage(question)], {
instructions: buildInstructions(systemRules, question, persona),
requestContext,
maxSteps: MAX_AGENT_STEPS,
@@ -68,7 +66,8 @@ export const createAssistantService = ({
return result.text.trim();
};
return { answer };
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
return { answer, canSeeImages: imageInput };
};
export type AssistantService = ReturnType<typeof createAssistantService>;

View File

@@ -1,17 +1,12 @@
import { displayName } from "../../libs/telegram-format.js";
import { formatUtc } from "../../libs/time.js";
import type { ChatMember } from "../../types.js";
import { formatTime } from "./format-chat-messages.js";
export const memberName = (member: ChatMember): string => {
const name = [member.firstName, member.lastName].filter(Boolean).join(" ");
return member.username ? `${name} (@${member.username})` : name;
};
const activity = (member: ChatMember): string =>
member.lastSeen > 0
? `сообщений: ${member.messageCount}, последнее: ${formatTime(member.lastSeen)} UTC`
: "ещё не писал(а) при боте";
? `сообщений в истории: ${member.messageCount}, последнее: ${formatUtc(member.lastSeen)}`
: "в сохранённой истории сообщений нет";
/** Участники в текстовом виде для контекста модели. */
export const formatChatMembers = (members: ChatMember[]): string =>
members.map((member) => `• ${memberName(member)} [id ${member.userId}] — ${activity(member)}`).join("\n");
members.map((member) => `• ${displayName(member)} [id ${member.userId}] — ${activity(member)}`).join("\n");

View File

@@ -1,14 +1,12 @@
import { formatUtc } from "../../libs/time.js";
import type { ChatMessage } from "../../types.js";
export const formatTime = (unixSeconds: number): string =>
new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");
/** Сообщения чата в текстовом виде для контекста модели. */
export const formatChatMessages = (messages: ChatMessage[]): string =>
messages
.map((m) => {
const reply = m.replyToMessageId ? ` (ответ на #${m.replyToMessageId})` : "";
return `#${m.messageId} [${formatTime(m.date)} UTC] ${m.author}${reply}: ${m.text}`;
return `#${m.messageId} [${formatUtc(m.date)}] ${m.author}${reply}: ${m.text}`;
})
.join("\n");

View File

@@ -1,4 +1,4 @@
import type { ChatMember, ChatMessage, ImageAttachment, Persona } from "../../types.js";
import type { ChatMember, ChatMessage, ImageAttachment, ImageSource, Persona } from "../../types.js";
import { formatChatMembers } from "./format-chat-members.js";
import { formatChatMessages } from "./format-chat-messages.js";
@@ -7,6 +7,7 @@ export interface Question {
chatTitle: string;
/** Как зовут спрашивающего: имя, фамилия и ник. */
askerName: string;
/** Может быть пустым, если прислали только картинку. */
text: string;
/** Сообщение, на которое ответил спрашивающий, — сразу передаём его как контекст. */
repliedTo?: ChatMessage;
@@ -14,20 +15,22 @@ export interface Question {
mentionedMembers: ChatMember[];
/** Упомянутые ники, которых бот в чате не встречал. */
unknownUsernames: string[];
/** Фото из вопроса и из сообщения, на которое ответили. */
/** Где в вопросе есть картинки — даже если модели их не отправляем. */
imageSources: ImageSource[];
/** Скачанные картинки для модели; пусто, если модель их не видит или скачать не вышло. */
images: ImageAttachment[];
}
const IMAGE_SOURCES: Record<ImageAttachment["source"], string> = {
const IMAGE_SOURCES: Record<ImageSource, string> = {
question: "из вопроса",
replied: "из сообщения, на которое ответили",
};
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
const imagesNote = (images: ImageAttachment[], canSeeImages: boolean): string | undefined => {
if (images.length === 0) return undefined;
const imagesNote = ({ imageSources, images }: Question): string | undefined => {
if (imageSources.length === 0) return undefined;
if (!canSeeImages) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
if (images.length === 0) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`;
};
@@ -54,12 +57,12 @@ export const buildInstructions = (systemRules: string, question: Question, perso
.filter(Boolean)
.join("\n\n");
export const buildPrompt = (question: Question, canSeeImages: boolean): string => {
export const buildPrompt = (question: Question): string => {
const replied = question.repliedTo
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
: undefined;
const text = replied ? `Вопрос: ${question.text}` : question.text;
const asked = question.text || "(текста нет — прислали только изображение, опиши и прокомментируй его)";
return [replied, imagesNote(question.images, canSeeImages), text].filter(Boolean).join("\n\n");
return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n");
};

View File

@@ -2,7 +2,8 @@ import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import type { ChatHistoryService } from "../../chat-history-service.js";
import type { ChatMemberService } from "../../chat-member-service.js";
import { formatChatMembers, memberName } from "../format-chat-members.js";
import { displayName } from "../../../libs/telegram-format.js";
import { formatChatMembers } from "../format-chat-members.js";
import { formatChatMessages } from "../format-chat-messages.js";
import { assistantRequestContextSchema } from "./request-context.js";
@@ -48,7 +49,7 @@ export const createReadMemberMessagesTool = ({ chatHistoryService, chatMemberSer
return {
status: "ok",
member: memberName(found),
member: displayName(found),
count: messages.length,
result: messages.length > 0 ? formatChatMessages(messages) : "Сохранённых сообщений этого участника нет.",
};

View File

@@ -8,8 +8,8 @@ interface ChatMemberServiceDeps {
export interface MentionQuery {
/** Ники из `@username` без собаки. */
usernames: string[];
/** Пользователи из упоминаний по имени-ссылке (у них может не быть ника). */
profiles: MemberProfile[];
/** ID людей, упомянутых по имени-ссылке (у них может не быть ника). */
userIds: number[];
}
export interface ResolvedMentions {
@@ -37,21 +37,14 @@ const nameMatches = (query: string) => (member: ChatMember) => {
.every((token) => memberWords.some((word) => word.startsWith(token)));
};
const uniqueByUser = (members: ChatMember[]): ChatMember[] =>
members.filter((member, index) => members.findIndex((m) => m.userId === member.userId) === index);
export const createChatMemberService = ({ chatMemberRepository }: ChatMemberServiceDeps) => {
/** Участник написал сообщение: обновляем имя, ник, счётчик и время активности. */
const recordActivity = (chatId: number, profile: MemberProfile, date: number): void =>
chatMemberRepository.upsert(chatId, profile, { messages: 1, seenAt: date });
/** Запоминаем имя и ник без учёта активности (правка сообщения, упоминание по ссылке). */
const remember = (chatId: number, profile: MemberProfile): void =>
chatMemberRepository.upsert(chatId, profile, { messages: 0, seenAt: 0 });
/** Запоминаем актуальные имя и ник: автор сообщения или человек, упомянутый по ссылке. */
const remember = (chatId: number, profile: MemberProfile): void => chatMemberRepository.upsert(chatId, profile);
/** Участники с активностью по сохранённой истории; сначала — недавно писавшие. */
const list = (chatId: number): ChatMember[] => chatMemberRepository.listByChat(chatId);
/** Поиск по нику (с @ или без) или по имени/фамилии; сначала — недавно активные. */
/** Поиск по нику (с @ или без) или по имени/фамилии. */
const search = (chatId: number, query: string): ChatMember[] => {
const cleaned = query.trim().replace(/^@/, "");
@@ -64,24 +57,21 @@ export const createChatMemberService = ({ chatMemberRepository }: ChatMemberServ
};
/** Превращает упоминания из вопроса в известных участников чата. */
const resolveMentions = (chatId: number, { usernames, profiles }: MentionQuery): ResolvedMentions => {
profiles.forEach((profile) => remember(chatId, profile));
const resolveMentions = (chatId: number, { usernames, userIds }: MentionQuery): ResolvedMentions => {
if (usernames.length === 0 && userIds.length === 0) return { members: [], unknownUsernames: [] };
const members = list(chatId);
const byUsername = usernames.map((username) => ({ username, member: members.find(usernameIs(username)) }));
const unknownUsernames = usernames.filter((username) => !members.some(usernameIs(username)));
const byProfile = profiles
.map((profile) => chatMemberRepository.findById(chatId, profile.userId))
.filter((member) => member !== undefined);
const mentioned = members.filter(
(member) => userIds.includes(member.userId) || usernames.some((username) => usernameIs(username)(member)),
);
return {
members: uniqueByUser([...byUsername.flatMap(({ member }) => (member ? [member] : [])), ...byProfile]),
unknownUsernames: byUsername.filter(({ member }) => !member).map(({ username }) => username),
};
return { members: mentioned, unknownUsernames };
};
return { recordActivity, remember, list, search, resolveMentions };
return { remember, list, search, resolveMentions };
};
export type ChatMemberService = ReturnType<typeof createChatMemberService>;

View File

@@ -20,23 +20,25 @@ export interface ChatMessage {
/** Участник чата, которого бот видел: писал сам или был упомянут по имени-ссылке. */
export interface ChatMember {
chatId: number;
userId: number;
username: string | null;
firstName: string;
lastName: string | null;
/** Сколько его сообщений в сохранённой истории чата. */
messageCount: number;
/** Unix-время последнего сообщения; 0 — ещё не писал (только упоминали). */
/** Unix-время последнего сохранённого сообщения; 0 — сообщений в истории нет. */
lastSeen: number;
}
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
export type MemberProfile = Pick<ChatMember, "userId" | "username" | "firstName" | "lastName">;
/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */
export type ImageSource = "question" | "replied";
/** Картинка, которую бот передаёт модели вместе с вопросом. */
export interface ImageAttachment {
data: Uint8Array;
mimeType: string;
/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */
source: "question" | "replied";
source: ImageSource;
}