Упрощение: общие хелперы, команды и упоминания по 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:
@@ -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));
|
||||
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user