- Бот запоминает участников (имя, фамилия, ник, активность) и ID автора каждого сообщения - Упоминания в вопросе (@ник и по имени-ссылке) передаются модели с данными участника - Новые инструменты агента: list_chat_members, read_member_messages (поиск по нику/имени) - Бот обращается к людям по имени - Промпты вынесены в prompts/system.md и prompts/personas/<id>.md - Личности больше не редактируются из чата: скрипт seed синхронизирует их с БД при старте - Оставлена одна личность boltun (Dirty D) - Миграция старых баз: колонка messages.user_id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
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();
|