Упрощение: общие хелперы, команды и упоминания по 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

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

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