- Одна 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>
78 lines
3.4 KiB
TypeScript
78 lines
3.4 KiB
TypeScript
import type { ChatMemberRepository } from "../repositories/chat-member-repository.js";
|
||
import type { ChatMember, MemberProfile } from "../types.js";
|
||
|
||
interface ChatMemberServiceDeps {
|
||
chatMemberRepository: ChatMemberRepository;
|
||
}
|
||
|
||
export interface MentionQuery {
|
||
/** Ники из `@username` без собаки. */
|
||
usernames: string[];
|
||
/** ID людей, упомянутых по имени-ссылке (у них может не быть ника). */
|
||
userIds: number[];
|
||
}
|
||
|
||
export interface ResolvedMentions {
|
||
members: ChatMember[];
|
||
/** Ники, которых бот в этом чате не встречал. */
|
||
unknownUsernames: string[];
|
||
}
|
||
|
||
/** Регистр и «ё» не важны: «Алёна» найдётся по «алена». */
|
||
const normalize = (value: string): string => value.toLocaleLowerCase("ru").replaceAll("ё", "е").trim();
|
||
|
||
const words = (member: ChatMember): string[] =>
|
||
[member.firstName, member.lastName ?? "", member.username ?? ""].flatMap((part) => normalize(part).split(/\s+/));
|
||
|
||
const usernameIs = (username: string) => (member: ChatMember) =>
|
||
member.username !== null && normalize(member.username) === normalize(username);
|
||
|
||
/** Каждое слово запроса — начало одного из слов имени или ника: «петр» найдёт «Петров». */
|
||
const nameMatches = (query: string) => (member: ChatMember) => {
|
||
const memberWords = words(member);
|
||
|
||
return normalize(query)
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.every((token) => memberWords.some((word) => word.startsWith(token)));
|
||
};
|
||
|
||
export const createChatMemberService = ({ chatMemberRepository }: ChatMemberServiceDeps) => {
|
||
/** Запоминаем актуальные имя и ник: автор сообщения или человек, упомянутый по ссылке. */
|
||
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(/^@/, "");
|
||
|
||
if (!cleaned) return [];
|
||
|
||
const members = list(chatId);
|
||
const byUsername = members.filter(usernameIs(cleaned));
|
||
|
||
return byUsername.length > 0 ? byUsername : members.filter(nameMatches(cleaned));
|
||
};
|
||
|
||
/** Превращает упоминания из вопроса в известных участников чата. */
|
||
const resolveMentions = (chatId: number, { usernames, userIds }: MentionQuery): ResolvedMentions => {
|
||
if (usernames.length === 0 && userIds.length === 0) return { members: [], unknownUsernames: [] };
|
||
|
||
const members = list(chatId);
|
||
|
||
const unknownUsernames = usernames.filter((username) => !members.some(usernameIs(username)));
|
||
|
||
const mentioned = members.filter(
|
||
(member) => userIds.includes(member.userId) || usernames.some((username) => usernameIs(username)(member)),
|
||
);
|
||
|
||
return { members: mentioned, unknownUsernames };
|
||
};
|
||
|
||
return { remember, list, search, resolveMentions };
|
||
};
|
||
|
||
export type ChatMemberService = ReturnType<typeof createChatMemberService>;
|