diff --git a/.env.example b/.env.example index 826bc73..5ff0815 100644 --- a/.env.example +++ b/.env.example @@ -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-базе и сколько сообщений на чат хранить diff --git a/README.md b/README.md index 467b6f1..df1db73 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 1accfb3..69e570c 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -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)); diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts index e3c51d9..8516c88 100644 --- a/src/bot/handlers/context-handlers.ts +++ b/src/bot/handlers/context-handlers.ts @@ -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 => { 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}.`, diff --git a/src/bot/handlers/history-handlers.ts b/src/bot/handlers/history-handlers.ts index 7b20c9f..b2f6fa2 100644 --- a/src/bot/handlers/history-handlers.ts +++ b/src/bot/handlers/history-handlers.ts @@ -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); + recordMessage(msg); + [...author, ...mentionedProfiles(msg)].forEach((profile) => chatMemberService.remember(msg.chat.id, profile)); - if (ctx.from && !ctx.from.is_bot) chatMemberService.recordActivity(ctx.chat.id, toMemberProfile(ctx.from), ctx.msg.date); - - await next(); - }); - - // Правка только обновляет историю: команды из отредактированных сообщений не выполняются повторно. - 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; diff --git a/src/bot/handlers/question-handlers.ts b/src/bot/handlers/question-handlers.ts index 4334ff0..e7e4dff 100644 --- a/src/bot/handlers/question-handlers.ts +++ b/src/bot/handlers/question-handlers.ts @@ -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; diff --git a/src/bot/images.ts b/src/bot/images.ts index 416b179..ec74ab1 100644 --- a/src/bot/images.ts +++ b/src/bot/images.ts @@ -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 | 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 => { - const { file_path: filePath, file_size: fileSize } = await api.getFile(file.fileId); +const download = async (api: Api, ref: ImageRef): Promise => { + 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 => { 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 => { - 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 => { + 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); }; diff --git a/src/config.ts b/src/config.ts index eb723a4..9083200 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = { 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 { - botToken: required("BOT_TOKEN"), - adminIds: parseAdminIds(required("ADMIN_IDS")), - aiModel, - /** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */ - aiImageInput: parseImageInput(aiModel), - /** Сколько последних сообщений каждого чата хранить в базе. */ - historyLimit: positiveInt("HISTORY_LIMIT", 2000), - }; -}; +export const loadBotConfig = () => ({ + botToken: required("BOT_TOKEN"), + adminIds: parseAdminIds(required("ADMIN_IDS")), + ai: parseModel(), + /** Сколько последних сообщений каждого чата хранить в базе. */ + historyLimit: positiveInt("HISTORY_LIMIT", 2000), +}); diff --git a/src/index.ts b/src/index.ts index 9bb1b8c..2daa2af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ? "да" : "нет"}`), }); diff --git a/src/libs/db.ts b/src/libs/db.ts index 28bccec..9f440f1 100644 --- a/src/libs/db.ts +++ b/src/libs/db.ts @@ -28,13 +28,11 @@ const SCHEMA = ` ); CREATE TABLE IF NOT EXISTS chat_members ( - chat_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - username TEXT, - first_name TEXT NOT NULL, - last_name TEXT, - message_count INTEGER NOT NULL DEFAULT 0, - last_seen INTEGER NOT NULL DEFAULT 0, + chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + username TEXT, + first_name TEXT NOT NULL, + last_name TEXT, 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); diff --git a/src/libs/prompts.ts b/src/libs/prompts.ts index 1b38675..4a69fd5 100644 --- a/src/libs/prompts.ts +++ b/src/libs/prompts.ts @@ -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(); diff --git a/src/libs/telegram-format.ts b/src/libs/telegram-format.ts index 618c7c0..e986f5a 100644 --- a/src/libs/telegram-format.ts +++ b/src/libs/telegram-format.ts @@ -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(); diff --git a/src/libs/time.ts b/src/libs/time.ts new file mode 100644 index 0000000..a34a627 --- /dev/null +++ b/src/libs/time.ts @@ -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`; diff --git a/src/repositories/chat-member-repository.ts b/src/repositories/chat-member-repository.ts index 1200b38..f7ebbc8 100644 --- a/src/repositories/chat-member-repository.ts +++ b/src/repositories/chat-member-repository.ts @@ -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) + username = excluded.username, + first_name = excluded.first_name, + 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; diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index b671abe..3ef8e45 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -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; diff --git a/src/services/assistant/format-chat-members.ts b/src/services/assistant/format-chat-members.ts index fb2fcc7..3383ead 100644 --- a/src/services/assistant/format-chat-members.ts +++ b/src/services/assistant/format-chat-members.ts @@ -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"); diff --git a/src/services/assistant/format-chat-messages.ts b/src/services/assistant/format-chat-messages.ts index 0dc0d9d..0f12a10 100644 --- a/src/services/assistant/format-chat-messages.ts +++ b/src/services/assistant/format-chat-messages.ts @@ -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"); diff --git a/src/services/assistant/prompts.ts b/src/services/assistant/prompts.ts index 3252de6..a4970af 100644 --- a/src/services/assistant/prompts.ts +++ b/src/services/assistant/prompts.ts @@ -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 = { +const IMAGE_SOURCES: Record = { 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"); }; diff --git a/src/services/assistant/tools/read-member-messages-tool.ts b/src/services/assistant/tools/read-member-messages-tool.ts index 27372da..10923ee 100644 --- a/src/services/assistant/tools/read-member-messages-tool.ts +++ b/src/services/assistant/tools/read-member-messages-tool.ts @@ -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) : "Сохранённых сообщений этого участника нет.", }; diff --git a/src/services/chat-member-service.ts b/src/services/chat-member-service.ts index 34fd9e9..60550ba 100644 --- a/src/services/chat-member-service.ts +++ b/src/services/chat-member-service.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index cad4bcb..7ef1a01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */ +export type ImageSource = "question" | "replied"; + /** Картинка, которую бот передаёт модели вместе с вопросом. */ export interface ImageAttachment { data: Uint8Array; mimeType: string; - /** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */ - source: "question" | "replied"; + source: ImageSource; }