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