Участники чата, промпты в файлах, личности только из кода

- Бот запоминает участников (имя, фамилия, ник, активность) и 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>
This commit is contained in:
yunogasai
2026-09-24 18:42:23 +02:00
parent 99c4660da9
commit 26da4bb5ee
35 changed files with 567 additions and 241 deletions

View File

@@ -2,8 +2,6 @@ export const BOT_COMMANDS = [
{ command: "ask", description: "Задать вопрос боту" },
{ command: "personas", description: "Список личностей и выбор" },
{ command: "persona", description: "Выбрать личность: /persona <id>" },
{ command: "persona_add", description: "Добавить личность: /persona_add <id> <описание>" },
{ command: "persona_del", description: "Удалить пользовательскую личность: /persona_del <id>" },
{ command: "help", description: "Справка" },
];
@@ -15,12 +13,11 @@ export const helpText = (username: string) => `Я отвечаю только а
• в личке — просто напиши
Ответь командой /ask на чьё-то сообщение — я учту его. Попроси «посмотри последние 50 сообщений» — я прочитаю переписку чата.
Участников узнаю по @нику и по имени: «что писал Иван?», «что думает @ivan про это?».
Чтобы я видел переписку в группе, отключи privacy mode в @BotFather (/setprivacy → Disable) или сделай меня админом группы.
Личности:
• /personas — список и выбор кнопками
• /persona <id> — выбрать
• /persona_add <id> <описание характера> — добавить или изменить пользовательскую
• /persona_del <id> — удалить пользовательскую
/whoami — узнать свой Telegram ID (в личке).`;

View File

@@ -1,6 +1,7 @@
import { Bot } 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";
import type { PersonaService } from "../services/persona-service.js";
import { createHelpHandlers, createPublicHandlers } from "./handlers/general-handlers.js";
import { createHistoryHandlers } from "./handlers/history-handlers.js";
@@ -12,13 +13,21 @@ interface BotDeps {
adminIds: ReadonlySet<number>;
personaService: PersonaService;
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
assistantService: AssistantService;
}
export const createBot = ({ token, adminIds, personaService, chatHistoryService, assistantService }: BotDeps): Bot => {
export const createBot = ({
token,
adminIds,
personaService,
chatHistoryService,
chatMemberService,
assistantService,
}: BotDeps): Bot => {
const bot = new Bot(token);
bot.use(createHistoryHandlers(chatHistoryService));
bot.use(createHistoryHandlers({ chatHistoryService, chatMemberService }));
bot.use(createPublicHandlers());
// Всё, что ниже, доступно только администраторам; остальных бот молча игнорирует.
@@ -26,7 +35,7 @@ export const createBot = ({ token, adminIds, personaService, chatHistoryService,
admin.use(createHelpHandlers());
admin.use(createPersonaHandlers(personaService));
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService }));
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
bot.on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));

View File

@@ -1,20 +1,43 @@
import { Composer } from "grammy";
import type { Message } from "grammy/types";
import { mentionedUsers, 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";
/** Запоминает сообщения всех участников — из них бот читает контекст по просьбе. */
export const createHistoryHandlers = (chatHistoryService: ChatHistoryService) => {
interface HistoryHandlersDeps {
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
}
/** Запоминает сообщения и участников чата — из них бот берёт контекст по просьбе. */
export const createHistoryHandlers = ({ chatHistoryService, chatMemberService }: HistoryHandlersDeps) => {
const composer = new Composer();
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", async (ctx, next) => {
recordMessage(ctx.msg);
rememberMentioned(ctx.msg);
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));
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));
});
return composer;
};

View File

@@ -8,13 +8,6 @@ const CALLBACK_PREFIX = "persona:";
const CALLBACK_PATTERN = new RegExp(`^${CALLBACK_PREFIX}(.+)$`);
const ADD_USAGE =
"Формат: /persona_add <id> <описание характера>\n" +
"id — латиница, цифры, _ или - (до 32 символов).\n" +
"Пример: /persona_add gopnik Ты гопник с района, говоришь с понятиями, но помогаешь.";
const DEL_USAGE = "Формат: /persona_del <id>. Список: /personas";
const preview = (prompt: string): string =>
prompt.length > PROMPT_PREVIEW_LENGTH ? `${prompt.slice(0, PROMPT_PREVIEW_LENGTH)}…` : prompt;
@@ -27,9 +20,8 @@ const buildKeyboard = (personas: Persona[], activeId: string): InlineKeyboard =>
const buildListText = (personas: Persona[], activeId: string): string => {
const lines = personas.map((p) => {
const mark = p.id === activeId ? "✅" : "•";
const kind = p.builtin ? "" : " (пользовательская)";
return `${mark} ${p.name} [${p.id}]${kind}\n ${preview(p.prompt)}`;
return `${mark} ${p.name} [${p.id}]\n ${preview(p.prompt)}`;
});
return `Личности бота:\n\n${lines.join("\n\n")}`;
@@ -83,41 +75,5 @@ export const createPersonaHandlers = (personaService: PersonaService) => {
await ctx.editMessageText(text, { reply_markup }).catch(() => {});
});
composer.command("persona_add", async (ctx) => {
const args = ctx.match.trim();
const [rawId = ""] = args.split(/\s+/);
const result = personaService.saveCustom(rawId, args.slice(rawId.length));
const errors = {
invalid: ADD_USAGE,
builtin: `«${result.id}» — встроенная личность, её нельзя изменить. Выбери другой id.`,
};
await ctx.reply(
result.ok
? `${result.updated ? "Обновил" : "Добавил"} личность «${result.id}». Включить: /persona ${result.id}`
: errors[result.reason],
);
});
composer.command("persona_del", async (ctx) => {
const id = ctx.match.trim();
if (!id) {
await ctx.reply(DEL_USAGE);
return;
}
const result = personaService.removeCustom(id);
const errors = {
not_found: `Личность «${result.id}» не найдена. Список: /personas`,
builtin: "Встроенные личности удалить нельзя.",
};
await ctx.reply(result.ok ? `Удалил личность «${result.id}».` : errors[result.reason]);
});
return composer;
};

View File

@@ -1,7 +1,16 @@
import { Composer, type Context } from "grammy";
import { displayName, mentions, removeMention, toChatMessage } from "../../libs/telegram-format.js";
import {
displayName,
mentionedUsernames,
mentionedUsers,
mentions,
removeMention,
toChatMessage,
toMemberProfile,
} from "../../libs/telegram-format.js";
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";
import type { PersonaService } from "../../services/persona-service.js";
import { createReplier, withTyping } from "../reply.js";
@@ -9,6 +18,7 @@ interface QuestionHandlersDeps {
assistantService: AssistantService;
personaService: PersonaService;
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
}
const chatTitle = (ctx: Context): string => {
@@ -18,7 +28,12 @@ const chatTitle = (ctx: Context): string => {
};
/** Вопросы боту: /ask, упоминание, ответ на сообщение бота, любое сообщение в личке. Только за фильтром админа. */
export const createQuestionHandlers = ({ assistantService, personaService, chatHistoryService }: QuestionHandlersDeps) => {
export const createQuestionHandlers = ({
assistantService,
personaService,
chatHistoryService,
chatMemberService,
}: QuestionHandlersDeps) => {
const composer = new Composer();
const reply = createReplier(chatHistoryService);
@@ -31,6 +46,16 @@ export const createQuestionHandlers = ({ assistantService, personaService, chatH
return chatHistoryService.find(original.chat.id, original.message_id) ?? toChatMessage(original);
};
/** Кого упомянули в вопросе (кроме самого бота) — подсказываем модели, кто эти люди. */
const resolveMentions = (ctx: Context) => {
const msg = ctx.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, text: string) => {
if (!text) {
await reply(ctx, "Задай вопрос: /ask <вопрос>");
@@ -39,12 +64,16 @@ export const createQuestionHandlers = ({ assistantService, personaService, chatH
const chatId = ctx.chat!.id;
const { members, unknownUsernames } = resolveMentions(ctx);
const question = {
chatId,
chatTitle: chatTitle(ctx),
askerName: displayName(ctx.from!),
text,
repliedTo: repliedToMessage(ctx),
mentionedMembers: members,
unknownUsernames,
};
try {