import type { Message, MessageEntity, User } from "grammy/types"; import type { ChatMessage, MemberProfile } from "../types.js"; const TELEGRAM_MESSAGE_LIMIT = 4096; export const toMemberProfile = (user: User): MemberProfile => ({ userId: user.id, username: user.username ?? null, firstName: user.first_name, lastName: user.last_name ?? null, }); export const displayName = (user: User): string => { const name = [user.first_name, user.last_name].filter(Boolean).join(" "); return user.username ? `${name} (@${user.username})` : name; }; const withLabel = (label: string, body: string | undefined): string => (body ? `${label} ${body}` : label); /** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */ export const messageText = (msg: Message): string | undefined => { const body = msg.text ?? msg.caption; if (msg.photo) return withLabel("[фото]", body); if (msg.video) return withLabel("[видео]", body); 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.poll) return withLabel("[опрос]", msg.poll.question); return body; }; /** В темах форума каждое сообщение формально отвечает на первое сообщение темы — это не настоящий ответ. */ const replyToMessageId = (msg: Message): number | null => { const original = msg.reply_to_message; if (!original || original.forum_topic_created) return null; return original.message_id; }; export const toChatMessage = (msg: Message): ChatMessage | undefined => { const text = messageText(msg); if (!text) return undefined; return { chatId: msg.chat.id, messageId: msg.message_id, userId: msg.from?.id ?? null, date: msg.date, author: msg.from ? displayName(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 ?? [], }); /** Ники из упоминаний `@username` (без собаки), без повторов. */ export const mentionedUsernames = (msg: Message): string[] => { const { text, entities } = messageEntities(msg); const usernames = entities .filter((entity) => entity.type === "mention") .map((entity) => text.slice(entity.offset + 1, entity.offset + entity.length)); 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 splitMessage = (text: string): string[] => { if (text.length <= TELEGRAM_MESSAGE_LIMIT) return text ? [text] : []; const newline = text.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT); const cut = newline > TELEGRAM_MESSAGE_LIMIT / 2 ? newline : TELEGRAM_MESSAGE_LIMIT; 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();