Упрощение: общие хелперы, команды и упоминания по 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:
@@ -13,7 +13,7 @@ OPENROUTER_API_KEY=
|
|||||||
# Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>,
|
# Необязательно: конкретная модель в формате Mastra model router <провайдер>/<модель>,
|
||||||
# например deepseek/deepseek-v4-pro или openrouter/google/gemini-3.8-flash
|
# например deepseek/deepseek-v4-pro или openrouter/google/gemini-3.8-flash
|
||||||
# AI_MODEL=
|
# AI_MODEL=
|
||||||
# Необязательно: умеет ли модель смотреть фото (по умолчанию — да для gemini/vision-моделей)
|
# Необязательно: умеет ли модель смотреть фото. Для своей AI_MODEL по умолчанию false — включи, если модель видит картинки
|
||||||
# AI_IMAGE_INPUT=true
|
# AI_IMAGE_INPUT=true
|
||||||
|
|
||||||
# Путь к SQLite-базе и сколько сообщений на чат хранить
|
# Путь к SQLite-базе и сколько сообщений на чат хранить
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pnpm dev
|
|||||||
| `OPENROUTER_API_KEY` | да* | ключ OpenRouter; модель по умолчанию — `openrouter/google/gemini-3.8-flash` (видит фото) |
|
| `OPENROUTER_API_KEY` | да* | ключ OpenRouter; модель по умолчанию — `openrouter/google/gemini-3.8-flash` (видит фото) |
|
||||||
| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek напрямую (api.deepseek.com); используется по умолчанию, если ключа OpenRouter нет — модель `deepseek/deepseek-flash`, только текст |
|
| `DEEPSEEK_API_KEY` | да* | ключ DeepSeek напрямую (api.deepseek.com); используется по умолчанию, если ключа OpenRouter нет — модель `deepseek/deepseek-flash`, только текст |
|
||||||
| `AI_MODEL` | нет | явная модель Mastra model router `<провайдер>/<модель>`, например `deepseek/deepseek-v4-pro` |
|
| `AI_MODEL` | нет | явная модель Mastra model router `<провайдер>/<модель>`, например `deepseek/deepseek-v4-pro` |
|
||||||
| `AI_IMAGE_INPUT` | нет | `true`/`false` — отправлять ли модели фото. По умолчанию `true` для моделей с `gemini`/`vision` в названии |
|
| `AI_IMAGE_INPUT` | нет | `true`/`false` — отправлять ли модели фото. Для моделей по умолчанию выставляется само (Gemini — да, DeepSeek — нет); для своей `AI_MODEL` по умолчанию `false` |
|
||||||
|
|
||||||
\* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте.
|
\* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте.
|
||||||
| `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) |
|
| `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot } from "grammy";
|
import { Bot, type Context } from "grammy";
|
||||||
import type { AssistantService } from "../services/assistant/assistant-service.js";
|
import type { AssistantService } from "../services/assistant/assistant-service.js";
|
||||||
import type { ChatHistoryService } from "../services/chat-history-service.js";
|
import type { ChatHistoryService } from "../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../services/chat-member-service.js";
|
import type { ChatMemberService } from "../services/chat-member-service.js";
|
||||||
@@ -31,8 +31,10 @@ export const createBot = ({
|
|||||||
bot.use(createHistoryHandlers({ chatHistoryService, chatMemberService }));
|
bot.use(createHistoryHandlers({ chatHistoryService, chatMemberService }));
|
||||||
bot.use(createPublicHandlers());
|
bot.use(createPublicHandlers());
|
||||||
|
|
||||||
|
const isAdmin = (ctx: Context) => ctx.from !== undefined && adminIds.has(ctx.from.id);
|
||||||
|
|
||||||
// Всё, что ниже, доступно только администраторам; остальных бот молча игнорирует.
|
// Всё, что ниже, доступно только администраторам; остальных бот молча игнорирует.
|
||||||
const admin = bot.filter((ctx) => ctx.from !== undefined && adminIds.has(ctx.from.id));
|
const admin = bot.filter(isAdmin);
|
||||||
|
|
||||||
admin.use(createHelpHandlers());
|
admin.use(createHelpHandlers());
|
||||||
admin.use(createPersonaHandlers(personaService));
|
admin.use(createPersonaHandlers(personaService));
|
||||||
@@ -40,7 +42,7 @@ export const createBot = ({
|
|||||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||||
|
|
||||||
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
|
// Иначе у не-админа кнопка «крутится», пока Telegram не отвалится по таймауту.
|
||||||
bot.on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));
|
bot.drop(isAdmin).on("callback_query", (ctx) => ctx.answerCallbackQuery({ text: "Только для администраторов" }));
|
||||||
|
|
||||||
bot.catch((err) => console.error(`Ошибка при обработке апдейта ${err.ctx.update.update_id}:`, err.error));
|
bot.catch((err) => console.error(`Ошибка при обработке апдейта ${err.ctx.update.update_id}:`, err.error));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Composer, InlineKeyboard, type Context } from "grammy";
|
import { Composer, InlineKeyboard, type Context } from "grammy";
|
||||||
|
import { formatUtc } from "../../libs/time.js";
|
||||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||||
|
|
||||||
@@ -11,9 +12,6 @@ interface ContextHandlersDeps {
|
|||||||
chatMemberService: ChatMemberService;
|
chatMemberService: ChatMemberService;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (unixSeconds: number): string =>
|
|
||||||
new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
|
|
||||||
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
||||||
const visibilityText = async (ctx: Context): Promise<string> => {
|
const visibilityText = async (ctx: Context): Promise<string> => {
|
||||||
if (ctx.chat?.type === "private") return "Это личка — я вижу все сообщения.";
|
if (ctx.chat?.type === "private") return "Это личка — я вижу все сообщения.";
|
||||||
@@ -37,7 +35,7 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService }:
|
|||||||
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
|
const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id);
|
||||||
const members = chatMemberService.list(ctx.chat.id).length;
|
const members = chatMemberService.list(ctx.chat.id).length;
|
||||||
|
|
||||||
const since = oldest === null ? "" : `, с ${formatDate(oldest)} UTC`;
|
const since = oldest === null ? "" : `, с ${formatUtc(oldest)}`;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
`Помню сообщений: ${count} из ${limit} возможных${since}.`,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Composer } from "grammy";
|
import { Composer } from "grammy";
|
||||||
import type { Message } from "grammy/types";
|
import { mentionedProfiles, toMemberProfile } from "../../libs/telegram-format.js";
|
||||||
import { mentionedUsers, toMemberProfile } from "../../libs/telegram-format.js";
|
|
||||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||||
import { createMessageRecorder } from "../reply.js";
|
import { createMessageRecorder } from "../reply.js";
|
||||||
@@ -16,27 +15,15 @@ export const createHistoryHandlers = ({ chatHistoryService, chatMemberService }:
|
|||||||
|
|
||||||
const recordMessage = createMessageRecorder(chatHistoryService);
|
const recordMessage = createMessageRecorder(chatHistoryService);
|
||||||
|
|
||||||
/** Имена людей, упомянутых по имени-ссылке, — чтобы потом узнавать их и без ника. */
|
composer.on(["message", "edited_message"], async (ctx, next) => {
|
||||||
const rememberMentioned = (msg: Message) =>
|
const msg = ctx.msg;
|
||||||
mentionedUsers(msg)
|
const author = ctx.from && !ctx.from.is_bot ? [toMemberProfile(ctx.from)] : [];
|
||||||
.filter((user) => !user.is_bot)
|
|
||||||
.forEach((user) => chatMemberService.remember(msg.chat.id, toMemberProfile(user)));
|
|
||||||
|
|
||||||
composer.on("message", async (ctx, next) => {
|
recordMessage(msg);
|
||||||
recordMessage(ctx.msg);
|
[...author, ...mentionedProfiles(msg)].forEach((profile) => chatMemberService.remember(msg.chat.id, profile));
|
||||||
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) => {
|
if (ctx.message) await next();
|
||||||
recordMessage(ctx.msg);
|
|
||||||
rememberMentioned(ctx.msg);
|
|
||||||
|
|
||||||
if (ctx.from && !ctx.from.is_bot) chatMemberService.remember(ctx.chat.id, toMemberProfile(ctx.from));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return composer;
|
return composer;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Composer, type Context } from "grammy";
|
import { Composer, type Context } from "grammy";
|
||||||
|
import type { Message } from "grammy/types";
|
||||||
import {
|
import {
|
||||||
|
commandOf,
|
||||||
displayName,
|
displayName,
|
||||||
|
mentionedProfiles,
|
||||||
mentionedUsernames,
|
mentionedUsernames,
|
||||||
mentionedUsers,
|
mentionsUser,
|
||||||
mentions,
|
textWithoutMention,
|
||||||
removeMention,
|
|
||||||
toChatMessage,
|
toChatMessage,
|
||||||
toMemberProfile,
|
toMemberProfile,
|
||||||
} from "../../libs/telegram-format.js";
|
} from "../../libs/telegram-format.js";
|
||||||
@@ -12,7 +14,7 @@ import type { AssistantService } from "../../services/assistant/assistant-servic
|
|||||||
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
import type { ChatHistoryService } from "../../services/chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
import type { ChatMemberService } from "../../services/chat-member-service.js";
|
||||||
import type { PersonaService } from "../../services/persona-service.js";
|
import type { PersonaService } from "../../services/persona-service.js";
|
||||||
import { collectImages, imageFileOf } from "../images.js";
|
import { downloadImages, imageRefsOf } from "../images.js";
|
||||||
import { createReplier, withTyping } from "../reply.js";
|
import { createReplier, withTyping } from "../reply.js";
|
||||||
|
|
||||||
interface QuestionHandlersDeps {
|
interface QuestionHandlersDeps {
|
||||||
@@ -22,16 +24,6 @@ interface QuestionHandlersDeps {
|
|||||||
chatMemberService: ChatMemberService;
|
chatMemberService: ChatMemberService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Прислали только картинку без текста — считаем, что спрашивают про неё. */
|
|
||||||
const IMAGE_ONLY_QUESTION = "Что на изображении?";
|
|
||||||
|
|
||||||
/** `/ask ...` в подписи к фото: grammY ищет команды только в тексте, подписи разбираем сами. */
|
|
||||||
const askInCaption = (caption: string | undefined, username: string): string | undefined => {
|
|
||||||
const command = caption?.match(new RegExp(`^/ask(?:@${username})?(?:\\s+|$)`, "i"));
|
|
||||||
|
|
||||||
return command ? caption!.slice(command[0].length).trim() : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatTitle = (ctx: Context): string => {
|
const chatTitle = (ctx: Context): string => {
|
||||||
if (!ctx.chat) return "неизвестный чат";
|
if (!ctx.chat) return "неизвестный чат";
|
||||||
|
|
||||||
@@ -49,8 +41,8 @@ export const createQuestionHandlers = ({
|
|||||||
|
|
||||||
const reply = createReplier(chatHistoryService);
|
const reply = createReplier(chatHistoryService);
|
||||||
|
|
||||||
const repliedToMessage = (ctx: Context) => {
|
const repliedToMessage = (msg: Message) => {
|
||||||
const original = ctx.msg?.reply_to_message;
|
const original = msg.reply_to_message;
|
||||||
|
|
||||||
if (!original) return undefined;
|
if (!original) return undefined;
|
||||||
|
|
||||||
@@ -58,42 +50,38 @@ export const createQuestionHandlers = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Кого упомянули в вопросе (кроме самого бота) — подсказываем модели, кто эти люди. */
|
/** Кого упомянули в вопросе (кроме самого бота) — подсказываем модели, кто эти люди. */
|
||||||
const resolveMentions = (ctx: Context) => {
|
const resolveMentions = (msg: Message, botUsername: string) =>
|
||||||
|
chatMemberService.resolveMentions(msg.chat.id, {
|
||||||
|
usernames: mentionedUsernames(msg).filter((u) => u.toLowerCase() !== botUsername.toLowerCase()),
|
||||||
|
userIds: mentionedProfiles(msg).map((profile) => profile.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerQuestion = async (ctx: Context, text: string) => {
|
||||||
const msg = ctx.msg!;
|
const msg = ctx.msg!;
|
||||||
|
const imageRefs = imageRefsOf(msg);
|
||||||
|
|
||||||
const usernames = mentionedUsernames(msg).filter((u) => u.toLowerCase() !== ctx.me.username.toLowerCase());
|
if (!text && imageRefs.length === 0) {
|
||||||
const profiles = mentionedUsers(msg).filter((user) => !user.is_bot).map(toMemberProfile);
|
|
||||||
|
|
||||||
return chatMemberService.resolveMentions(ctx.chat!.id, { usernames, profiles });
|
|
||||||
};
|
|
||||||
|
|
||||||
const answerQuestion = async (ctx: Context, rawText: string) => {
|
|
||||||
const msg = ctx.msg!;
|
|
||||||
const hasImage = imageFileOf(msg) !== undefined || imageFileOf(msg.reply_to_message) !== undefined;
|
|
||||||
const text = rawText || (hasImage ? IMAGE_ONLY_QUESTION : "");
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
await reply(ctx, "Задай вопрос: /ask <вопрос>");
|
await reply(ctx, "Задай вопрос: /ask <вопрос>");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatId = ctx.chat!.id;
|
|
||||||
|
|
||||||
const { members, unknownUsernames } = resolveMentions(ctx);
|
|
||||||
|
|
||||||
const ask = async () => {
|
const ask = async () => {
|
||||||
|
const images = assistantService.canSeeImages ? await downloadImages(ctx.api, imageRefs) : [];
|
||||||
|
const { members, unknownUsernames } = resolveMentions(msg, ctx.me.username);
|
||||||
|
|
||||||
const question = {
|
const question = {
|
||||||
chatId,
|
chatId: msg.chat.id,
|
||||||
chatTitle: chatTitle(ctx),
|
chatTitle: chatTitle(ctx),
|
||||||
askerName: displayName(ctx.from!),
|
askerName: displayName(toMemberProfile(ctx.from!)),
|
||||||
text,
|
text,
|
||||||
repliedTo: repliedToMessage(ctx),
|
repliedTo: repliedToMessage(msg),
|
||||||
mentionedMembers: members,
|
mentionedMembers: members,
|
||||||
unknownUsernames,
|
unknownUsernames,
|
||||||
images: hasImage ? await collectImages(ctx.api, msg) : [],
|
imageSources: imageRefs.map((ref) => ref.source),
|
||||||
|
images,
|
||||||
};
|
};
|
||||||
|
|
||||||
return assistantService.answer(question, personaService.getActive(chatId));
|
return assistantService.answer(question, personaService.getActive(msg.chat.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,27 +94,27 @@ export const createQuestionHandlers = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
composer.command("ask", (ctx) => answerQuestion(ctx, ctx.match.trim()));
|
// Текст или подпись к фото. /ask — где угодно; без команды: в личке — всегда,
|
||||||
|
// в группе — при упоминании бота или ответе на его сообщение.
|
||||||
// Текст или подпись к фото: в личке — всегда, в группе — при упоминании бота или ответе на его сообщение.
|
|
||||||
composer.on("message", async (ctx) => {
|
composer.on("message", async (ctx) => {
|
||||||
const text = ctx.msg.text ?? ctx.msg.caption ?? "";
|
const msg = ctx.msg;
|
||||||
const hasImage = imageFileOf(ctx.msg) !== undefined;
|
const command = commandOf(msg, ctx.me.username);
|
||||||
|
|
||||||
const captionQuestion = askInCaption(ctx.msg.caption, ctx.me.username);
|
if (command) {
|
||||||
|
if (command.name === "ask") await answerQuestion(ctx, command.args);
|
||||||
if (captionQuestion !== undefined) {
|
|
||||||
await answerQuestion(ctx, captionQuestion);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.startsWith("/") || (!text && !hasImage)) return;
|
const text = textWithoutMention(msg, ctx.me.username);
|
||||||
|
|
||||||
|
// Стикеры, голосовые и прочее без текста и картинки — не вопрос.
|
||||||
|
if (!text && imageRefsOf(msg).length === 0) return;
|
||||||
|
|
||||||
const isPrivate = ctx.chat.type === "private";
|
const isPrivate = ctx.chat.type === "private";
|
||||||
const mentioned = mentions(text, ctx.me.username);
|
const mentioned = mentionsUser(msg, ctx.me.username);
|
||||||
const repliedToBot = ctx.msg.reply_to_message?.from?.id === ctx.me.id;
|
const repliedToBot = msg.reply_to_message?.from?.id === ctx.me.id;
|
||||||
|
|
||||||
if (isPrivate || mentioned || repliedToBot) await answerQuestion(ctx, removeMention(text, ctx.me.username));
|
if (isPrivate || mentioned || repliedToBot) await answerQuestion(ctx, text);
|
||||||
});
|
});
|
||||||
|
|
||||||
return composer;
|
return composer;
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { Api } from "grammy";
|
import type { Api } from "grammy";
|
||||||
import type { Message } from "grammy/types";
|
import type { Message } from "grammy/types";
|
||||||
import type { ImageAttachment } from "../types.js";
|
import type { ImageAttachment, ImageSource } from "../types.js";
|
||||||
|
|
||||||
/** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */
|
/** Больше не качаем: модели хватает и сжатого фото, а документы бывают огромными. */
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
interface ImageFile {
|
export interface ImageRef {
|
||||||
|
source: ImageSource;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Картинка в сообщении: фото (берём самый крупный размер) или файл-изображение. */
|
/** Картинка в сообщении: фото (берём самый крупный размер) или файл-изображение. */
|
||||||
export const imageFileOf = (msg: Message | undefined): ImageFile | undefined => {
|
const imageFileOf = (msg: Message | undefined): Omit<ImageRef, "source"> | undefined => {
|
||||||
const photo = msg?.photo?.at(-1);
|
const photo = msg?.photo?.at(-1);
|
||||||
|
|
||||||
if (photo) return { fileId: photo.file_id, mimeType: "image/jpeg" };
|
if (photo) return { fileId: photo.file_id, mimeType: "image/jpeg" };
|
||||||
@@ -23,9 +24,16 @@ export const imageFileOf = (msg: Message | undefined): ImageFile | undefined =>
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Картинки вопроса: из самого сообщения и из того, на которое ответили. Без скачивания. */
|
||||||
|
export const imageRefsOf = (msg: Message): ImageRef[] =>
|
||||||
|
[
|
||||||
|
{ source: "question" as const, file: imageFileOf(msg) },
|
||||||
|
{ source: "replied" as const, file: imageFileOf(msg.reply_to_message) },
|
||||||
|
].flatMap(({ source, file }) => (file ? [{ source, ...file }] : []));
|
||||||
|
|
||||||
/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */
|
/** Скачивает файл через Bot API. Ссылку наружу не отдаём — в ней токен бота. */
|
||||||
const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
|
const download = async (api: Api, ref: ImageRef): Promise<ImageAttachment> => {
|
||||||
const { file_path: filePath, file_size: fileSize } = await api.getFile(file.fileId);
|
const { file_path: filePath, file_size: fileSize } = await api.getFile(ref.fileId);
|
||||||
|
|
||||||
if (!filePath) throw new Error("Telegram не отдал путь к файлу");
|
if (!filePath) throw new Error("Telegram не отдал путь к файлу");
|
||||||
if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`);
|
if (fileSize !== undefined && fileSize > MAX_IMAGE_BYTES) throw new Error(`Картинка слишком большая: ${fileSize} байт`);
|
||||||
@@ -34,28 +42,19 @@ const download = async (api: Api, file: ImageFile): Promise<Uint8Array> => {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`);
|
if (!response.ok) throw new Error(`Telegram вернул ${response.status} при скачивании файла`);
|
||||||
|
|
||||||
return new Uint8Array(await response.arrayBuffer());
|
return { data: new Uint8Array(await response.arrayBuffer()), mimeType: ref.mimeType, source: ref.source };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Фото из вопроса и из сообщения, на которое ответили. Не скачалось — пропускаем, а не роняем ответ. */
|
/** Скачивает картинки параллельно. Не скачалась — пропускаем, а не роняем ответ. */
|
||||||
export const collectImages = async (api: Api, msg: Message): Promise<ImageAttachment[]> => {
|
export const downloadImages = async (api: Api, refs: ImageRef[]): Promise<ImageAttachment[]> => {
|
||||||
const candidates = [
|
const images = await Promise.all(
|
||||||
{ source: "question" as const, file: imageFileOf(msg) },
|
refs.map((ref) =>
|
||||||
{ source: "replied" as const, file: imageFileOf(msg.reply_to_message) },
|
download(api, ref).catch((error: unknown) => {
|
||||||
];
|
|
||||||
|
|
||||||
const downloaded = await Promise.all(
|
|
||||||
candidates.map(async ({ source, file }) => {
|
|
||||||
if (!file) return undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return { data: await download(api, file), mimeType: file.mimeType, source };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Не удалось скачать картинку:", error);
|
console.error("Не удалось скачать картинку:", error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return downloaded.filter((image) => image !== undefined);
|
return images.filter((image) => image !== undefined);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
/** Значение переменной окружения без пробелов; undefined — если не задана или пустая. */
|
||||||
|
const envValue = (name: string): string | undefined => process.env[name]?.trim() || undefined;
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
const value = process.env[name]?.trim();
|
const value = envValue(name);
|
||||||
|
|
||||||
if (!value) throw new Error(`Не задана переменная окружения ${name}`);
|
if (!value) throw new Error(`Не задана переменная окружения ${name}`);
|
||||||
|
|
||||||
@@ -7,7 +10,7 @@ const required = (name: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const positiveInt = (name: string, fallback: number): number => {
|
const positiveInt = (name: string, fallback: number): number => {
|
||||||
const raw = process.env[name]?.trim();
|
const raw = envValue(name);
|
||||||
|
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
|
|
||||||
@@ -40,12 +43,20 @@ const PROVIDER_API_KEYS: Record<string, string> = {
|
|||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ключ OpenRouter — Gemini 3.8 Flash (умеет фото), иначе DeepSeek напрямую (только текст). */
|
interface ModelChoice {
|
||||||
const defaultModel = (): string =>
|
model: string;
|
||||||
process.env.OPENROUTER_API_KEY?.trim() ? "openrouter/google/gemini-3.8-flash" : "deepseek/deepseek-flash";
|
/** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */
|
||||||
|
imageInput: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть ключ OpenRouter — Gemini 3.8 Flash (видит фото), иначе DeepSeek напрямую (только текст). */
|
||||||
|
const defaultModel = (): ModelChoice =>
|
||||||
|
envValue("OPENROUTER_API_KEY")
|
||||||
|
? { model: "openrouter/google/gemini-3.8-flash", imageInput: true }
|
||||||
|
: { model: "deepseek/deepseek-flash", imageInput: false };
|
||||||
|
|
||||||
const parseBool = (name: string): boolean | undefined => {
|
const parseBool = (name: string): boolean | undefined => {
|
||||||
const raw = process.env[name]?.trim().toLowerCase();
|
const raw = envValue(name)?.toLowerCase();
|
||||||
|
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
if (["1", "true", "yes"].includes(raw)) return true;
|
if (["1", "true", "yes"].includes(raw)) return true;
|
||||||
@@ -54,40 +65,34 @@ const parseBool = (name: string): boolean | undefined => {
|
|||||||
throw new Error(`${name} должно быть true или false, получено "${raw}"`);
|
throw new Error(`${name} должно быть true или false, получено "${raw}"`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Умеет ли модель смотреть изображения: AI_IMAGE_INPUT, иначе угадываем по названию модели. */
|
/** Модель из AI_MODEL (фото — только если AI_IMAGE_INPUT=true) или по умолчанию + проверка ключа провайдера. */
|
||||||
const parseImageInput = (model: string): boolean => parseBool("AI_IMAGE_INPUT") ?? /gemini|vision/i.test(model);
|
const parseModel = (): ModelChoice => {
|
||||||
|
const customModel = envValue("AI_MODEL");
|
||||||
|
const hasAnyKey = Object.values(PROVIDER_API_KEYS).some((name) => envValue(name));
|
||||||
|
|
||||||
/** Модель из AI_MODEL (или по умолчанию) + проверка, что ключ её провайдера задан. */
|
if (!customModel && !hasAnyKey) {
|
||||||
const parseModel = (): string => {
|
throw new Error("Задай ключ модели: OPENROUTER_API_KEY или DEEPSEEK_API_KEY (DeepSeek напрямую)");
|
||||||
const hasAnyKey = Object.values(PROVIDER_API_KEYS).some((name) => process.env[name]?.trim());
|
|
||||||
|
|
||||||
if (!process.env.AI_MODEL?.trim() && !hasAnyKey) {
|
|
||||||
throw new Error("Задай ключ модели: DEEPSEEK_API_KEY (DeepSeek напрямую) или OPENROUTER_API_KEY");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = process.env.AI_MODEL?.trim() || defaultModel();
|
const fallback = defaultModel();
|
||||||
|
const model = customModel ?? fallback.model;
|
||||||
|
const imageInput = parseBool("AI_IMAGE_INPUT") ?? (customModel ? false : fallback.imageInput);
|
||||||
|
|
||||||
const [provider = ""] = model.split("/");
|
const [provider = ""] = model.split("/");
|
||||||
const keyName = PROVIDER_API_KEYS[provider];
|
const keyName = PROVIDER_API_KEYS[provider];
|
||||||
|
|
||||||
if (keyName) required(keyName);
|
if (keyName) required(keyName);
|
||||||
|
|
||||||
return model;
|
return { model, imageInput };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */
|
/** Путь к SQLite; нужен и боту, и скрипту `seed`, которому остальные переменные не нужны. */
|
||||||
export const DB_PATH = process.env.DB_PATH?.trim() || "data/bot.db";
|
export const DB_PATH = envValue("DB_PATH") ?? "data/bot.db";
|
||||||
|
|
||||||
export const loadBotConfig = () => {
|
export const loadBotConfig = () => ({
|
||||||
const aiModel = parseModel();
|
|
||||||
|
|
||||||
return {
|
|
||||||
botToken: required("BOT_TOKEN"),
|
botToken: required("BOT_TOKEN"),
|
||||||
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
||||||
aiModel,
|
ai: parseModel(),
|
||||||
/** Отправлять ли модели фото из вопроса и из сообщения, на которое ответили. */
|
|
||||||
aiImageInput: parseImageInput(aiModel),
|
|
||||||
/** Сколько последних сообщений каждого чата хранить в базе. */
|
/** Сколько последних сообщений каждого чата хранить в базе. */
|
||||||
historyLimit: positiveInt("HISTORY_LIMIT", 2000),
|
historyLimit: positiveInt("HISTORY_LIMIT", 2000),
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const chatHistoryService = createChatHistoryService({
|
|||||||
const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) });
|
const chatMemberService = createChatMemberService({ chatMemberRepository: createChatMemberRepository(db) });
|
||||||
|
|
||||||
const assistantService = createAssistantService({
|
const assistantService = createAssistantService({
|
||||||
model: config.aiModel,
|
model: config.ai.model,
|
||||||
imageInput: config.aiImageInput,
|
imageInput: config.ai.imageInput,
|
||||||
chatHistoryService,
|
chatHistoryService,
|
||||||
chatMemberService,
|
chatMemberService,
|
||||||
});
|
});
|
||||||
@@ -59,5 +59,5 @@ await bot.api.setMyCommands(BOT_COMMANDS);
|
|||||||
await bot.start({
|
await bot.start({
|
||||||
allowed_updates: ["message", "edited_message", "callback_query"],
|
allowed_updates: ["message", "edited_message", "callback_query"],
|
||||||
onStart: (me) =>
|
onStart: (me) =>
|
||||||
console.log(`Бот @${me.username} запущен, модель ${config.aiModel}, фото: ${config.aiImageInput ? "да" : "нет"}`),
|
console.log(`Бот @${me.username} запущен, модель ${config.ai.model}, фото: ${config.ai.imageInput ? "да" : "нет"}`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ const SCHEMA = `
|
|||||||
username TEXT,
|
username TEXT,
|
||||||
first_name TEXT NOT NULL,
|
first_name TEXT NOT NULL,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
message_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_seen INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (chat_id, user_id)
|
PRIMARY KEY (chat_id, user_id)
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
@@ -55,6 +53,8 @@ export const createDb = (path: string): Db => {
|
|||||||
const db = new Database(path);
|
const db = new Database(path);
|
||||||
|
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
|
// Каждое сообщение чата — запись в базу; с WAL NORMAL надёжен и не делает fsync на каждый коммит.
|
||||||
|
db.pragma("synchronous = NORMAL");
|
||||||
db.exec(SCHEMA);
|
db.exec(SCHEMA);
|
||||||
migrate(db);
|
migrate(db);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { readFileSync } from "node:fs";
|
|||||||
/** `prompts/` в корне проекта; путь одинаково работает из `src/libs` (tsx) и из `dist/libs` (сборка). */
|
/** `prompts/` в корне проекта; путь одинаково работает из `src/libs` (tsx) и из `dist/libs` (сборка). */
|
||||||
const PROMPTS_DIR = new URL("../../prompts/", import.meta.url);
|
const PROMPTS_DIR = new URL("../../prompts/", import.meta.url);
|
||||||
|
|
||||||
/** Читает текстовый промпт, например `readPrompt("personas/pirate.md")`. */
|
/** Читает текстовый промпт, например `readPrompt("personas/boltun.md")`. */
|
||||||
export const readPrompt = (relativePath: string): string =>
|
export const readPrompt = (relativePath: string): string =>
|
||||||
readFileSync(new URL(relativePath, PROMPTS_DIR), "utf8").trim();
|
readFileSync(new URL(relativePath, PROMPTS_DIR), "utf8").trim();
|
||||||
|
|||||||
@@ -10,27 +10,34 @@ export const toMemberProfile = (user: User): MemberProfile => ({
|
|||||||
lastName: user.last_name ?? null,
|
lastName: user.last_name ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const displayName = (user: User): string => {
|
/** «Имя Фамилия (@ник)» — одинаково для авторов сообщений, спрашивающего и списка участников. */
|
||||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ");
|
export const displayName = (profile: MemberProfile): string => {
|
||||||
|
const name = [profile.firstName, profile.lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return user.username ? `${name} (@${user.username})` : name;
|
return profile.username ? `${name} (@${profile.username})` : name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const withLabel = (label: string, body: string | undefined): string => (body ? `${label} ${body}` : label);
|
/** Текст сообщения или подпись к медиа — там, где Telegram держит текст и его entities. */
|
||||||
|
const messageBody = (msg: Message): { text: string; entities: MessageEntity[] } => ({
|
||||||
|
text: msg.text ?? msg.caption ?? "",
|
||||||
|
entities: msg.entities ?? msg.caption_entities ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withLabel = (label: string, body: string): string => (body ? `${label} ${body}` : label);
|
||||||
|
|
||||||
/** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */
|
/** Текстовое представление сообщения для истории; undefined — если сохранять нечего. */
|
||||||
export const messageText = (msg: Message): string | undefined => {
|
export const messageText = (msg: Message): string | undefined => {
|
||||||
const body = msg.text ?? msg.caption;
|
const { text } = messageBody(msg);
|
||||||
|
|
||||||
if (msg.photo) return withLabel("[фото]", body);
|
if (msg.photo) return withLabel("[фото]", text);
|
||||||
if (msg.video) return withLabel("[видео]", body);
|
if (msg.video) return withLabel("[видео]", text);
|
||||||
if (msg.voice) return "[голосовое сообщение]";
|
if (msg.voice) return "[голосовое сообщение]";
|
||||||
if (msg.video_note) return "[видеосообщение]";
|
if (msg.video_note) return "[видеосообщение]";
|
||||||
if (msg.sticker) return withLabel("[стикер]", msg.sticker.emoji);
|
if (msg.sticker) return withLabel("[стикер]", msg.sticker.emoji ?? "");
|
||||||
if (msg.document) return withLabel(`[файл ${msg.document.file_name ?? "без имени"}]`, body);
|
if (msg.document) return withLabel(`[файл ${msg.document.file_name ?? "без имени"}]`, text);
|
||||||
if (msg.poll) return withLabel("[опрос]", msg.poll.question);
|
if (msg.poll) return withLabel("[опрос]", msg.poll.question);
|
||||||
|
|
||||||
return body;
|
return text || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** В темах форума каждое сообщение формально отвечает на первое сообщение темы — это не настоящий ответ. */
|
/** В темах форума каждое сообщение формально отвечает на первое сообщение темы — это не настоящий ответ. */
|
||||||
@@ -52,31 +59,75 @@ export const toChatMessage = (msg: Message): ChatMessage | undefined => {
|
|||||||
messageId: msg.message_id,
|
messageId: msg.message_id,
|
||||||
userId: msg.from?.id ?? null,
|
userId: msg.from?.id ?? null,
|
||||||
date: msg.date,
|
date: msg.date,
|
||||||
author: msg.from ? displayName(msg.from) : (msg.sender_chat?.title ?? "Аноним"),
|
author: msg.from ? displayName(toMemberProfile(msg.from)) : (msg.sender_chat?.title ?? "Аноним"),
|
||||||
text,
|
text,
|
||||||
replyToMessageId: replyToMessageId(msg),
|
replyToMessageId: replyToMessageId(msg),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageEntities = (msg: Message): { text: string; entities: MessageEntity[] } => ({
|
const entityText = (text: string, entity: MessageEntity): string =>
|
||||||
text: msg.text ?? msg.caption ?? "",
|
text.slice(entity.offset, entity.offset + entity.length);
|
||||||
entities: msg.entities ?? msg.caption_entities ?? [],
|
|
||||||
});
|
const sameUsername = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
/** Ники из упоминаний `@username` (без собаки), без повторов. */
|
/** Ники из упоминаний `@username` (без собаки), без повторов. */
|
||||||
export const mentionedUsernames = (msg: Message): string[] => {
|
export const mentionedUsernames = (msg: Message): string[] => {
|
||||||
const { text, entities } = messageEntities(msg);
|
const { text, entities } = messageBody(msg);
|
||||||
|
|
||||||
const usernames = entities
|
const usernames = entities.filter((e) => e.type === "mention").map((e) => entityText(text, e).slice(1));
|
||||||
.filter((entity) => entity.type === "mention")
|
|
||||||
.map((entity) => text.slice(entity.offset + 1, entity.offset + entity.length));
|
|
||||||
|
|
||||||
return [...new Set(usernames)];
|
return [...new Set(usernames)];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Пользователи, упомянутые по имени-ссылке — так Telegram упоминает людей без ника. */
|
/** Люди (не боты), упомянутые по имени-ссылке — так Telegram упоминает тех, у кого нет ника. */
|
||||||
export const mentionedUsers = (msg: Message): User[] =>
|
export const mentionedProfiles = (msg: Message): MemberProfile[] =>
|
||||||
messageEntities(msg).entities.flatMap((entity) => (entity.type === "text_mention" ? [entity.user] : []));
|
messageBody(msg).entities.flatMap((e) =>
|
||||||
|
e.type === "text_mention" && !e.user.is_bot ? [toMemberProfile(e.user)] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mentionsUser = (msg: Message, username: string): boolean =>
|
||||||
|
mentionedUsernames(msg).some((u) => sameUsername(u, username));
|
||||||
|
|
||||||
|
/** Текст сообщения без упоминаний `@username` — вырезаем по entities, а не регуляркой. */
|
||||||
|
export const textWithoutMention = (msg: Message, username: string): string => {
|
||||||
|
const { text, entities } = messageBody(msg);
|
||||||
|
|
||||||
|
const mentions = entities
|
||||||
|
.filter((e) => e.type === "mention" && sameUsername(entityText(text, e).slice(1), username))
|
||||||
|
.sort((a, b) => b.offset - a.offset);
|
||||||
|
|
||||||
|
const cut = mentions.reduce((rest, e) => rest.slice(0, e.offset) + rest.slice(e.offset + e.length), text);
|
||||||
|
|
||||||
|
return cut
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/[ \t]+([,.!?])/g, "$1")
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BotCommand {
|
||||||
|
name: string;
|
||||||
|
/** Текст после команды. */
|
||||||
|
args: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Команда в начале текста или подписи к медиа (`/ask@bot вопрос`).
|
||||||
|
* grammY `command()` подписи не смотрит, поэтому разбираем entity сами.
|
||||||
|
* undefined — если команды нет или она адресована другому боту.
|
||||||
|
*/
|
||||||
|
export const commandOf = (msg: Message, botUsername: string): BotCommand | undefined => {
|
||||||
|
const { text, entities } = messageBody(msg);
|
||||||
|
|
||||||
|
const entity = entities.find((e) => e.type === "bot_command" && e.offset === 0);
|
||||||
|
|
||||||
|
if (!entity) return undefined;
|
||||||
|
|
||||||
|
const [name = "", target] = entityText(text, entity).slice(1).split("@");
|
||||||
|
|
||||||
|
if (target && !sameUsername(target, botUsername)) return undefined;
|
||||||
|
|
||||||
|
return { name: name.toLowerCase(), args: text.slice(entity.length).trim() };
|
||||||
|
};
|
||||||
|
|
||||||
/** Режет длинный ответ на части под лимит Telegram, по возможности по переносам строк. */
|
/** Режет длинный ответ на части под лимит Telegram, по возможности по переносам строк. */
|
||||||
export const splitMessage = (text: string): string[] => {
|
export const splitMessage = (text: string): string[] => {
|
||||||
@@ -87,17 +138,3 @@ export const splitMessage = (text: string): string[] => {
|
|||||||
|
|
||||||
return [text.slice(0, cut), ...splitMessage(text.slice(cut).trimStart())];
|
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();
|
|
||||||
|
|||||||
3
src/libs/time.ts
Normal file
3
src/libs/time.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/** Unix-время (секунды, как в Telegram) → «2026-09-24 18:00 UTC». */
|
||||||
|
export const formatUtc = (unixSeconds: number): string =>
|
||||||
|
`${new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
||||||
@@ -2,7 +2,6 @@ import type { Db } from "../libs/db.js";
|
|||||||
import type { ChatMember, MemberProfile } from "../types.js";
|
import type { ChatMember, MemberProfile } from "../types.js";
|
||||||
|
|
||||||
interface ChatMemberRow {
|
interface ChatMemberRow {
|
||||||
chat_id: number;
|
|
||||||
user_id: number;
|
user_id: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -12,7 +11,6 @@ interface ChatMemberRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toMember = (row: ChatMemberRow): ChatMember => ({
|
const toMember = (row: ChatMemberRow): ChatMember => ({
|
||||||
chatId: row.chat_id,
|
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
firstName: row.first_name,
|
firstName: row.first_name,
|
||||||
@@ -24,37 +22,35 @@ const toMember = (row: ChatMemberRow): ChatMember => ({
|
|||||||
export const createChatMemberRepository = (db: Db) => {
|
export const createChatMemberRepository = (db: Db) => {
|
||||||
const statements = {
|
const statements = {
|
||||||
upsert: db.prepare(`
|
upsert: db.prepare(`
|
||||||
INSERT INTO chat_members (chat_id, user_id, username, first_name, last_name, message_count, last_seen)
|
INSERT INTO chat_members (chat_id, user_id, username, first_name, last_name)
|
||||||
VALUES (@chatId, @userId, @username, @firstName, @lastName, @messages, @seenAt)
|
VALUES (@chatId, @userId, @username, @firstName, @lastName)
|
||||||
ON CONFLICT(chat_id, user_id) DO UPDATE SET
|
ON CONFLICT(chat_id, user_id) DO UPDATE SET
|
||||||
username = excluded.username,
|
username = excluded.username,
|
||||||
first_name = excluded.first_name,
|
first_name = excluded.first_name,
|
||||||
last_name = excluded.last_name,
|
last_name = excluded.last_name
|
||||||
message_count = message_count + excluded.message_count,
|
`),
|
||||||
last_seen = MAX(last_seen, excluded.last_seen)
|
// Активность считаем по сохранённой истории: после /clear и обрезки лимитом цифры остаются честными.
|
||||||
|
listByChat: db.prepare(`
|
||||||
|
SELECT m.user_id, m.username, m.first_name, m.last_name,
|
||||||
|
COUNT(msg.message_id) AS message_count,
|
||||||
|
COALESCE(MAX(msg.date), 0) AS last_seen
|
||||||
|
FROM chat_members m
|
||||||
|
LEFT JOIN messages msg ON msg.chat_id = m.chat_id AND msg.user_id = m.user_id
|
||||||
|
WHERE m.chat_id = ?
|
||||||
|
GROUP BY m.user_id
|
||||||
|
ORDER BY last_seen DESC
|
||||||
`),
|
`),
|
||||||
listByChat: db.prepare("SELECT * FROM chat_members WHERE chat_id = ? ORDER BY last_seen DESC"),
|
|
||||||
findById: db.prepare("SELECT * FROM chat_members WHERE chat_id = ? AND user_id = ?"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Сохраняет актуальные имя, фамилию и ник участника. */
|
||||||
* Сохраняет актуальные имя и ник участника.
|
const upsert = (chatId: number, profile: MemberProfile): void => {
|
||||||
* `messages` добавляется к счётчику сообщений, `seenAt` сдвигает время последней активности вперёд.
|
statements.upsert.run({ chatId, ...profile });
|
||||||
*/
|
|
||||||
const upsert = (chatId: number, profile: MemberProfile, activity: { messages: number; seenAt: number }): void => {
|
|
||||||
statements.upsert.run({ chatId, ...profile, ...activity });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const listByChat = (chatId: number): ChatMember[] =>
|
const listByChat = (chatId: number): ChatMember[] =>
|
||||||
(statements.listByChat.all(chatId) as ChatMemberRow[]).map(toMember);
|
(statements.listByChat.all(chatId) as ChatMemberRow[]).map(toMember);
|
||||||
|
|
||||||
const findById = (chatId: number, userId: number): ChatMember | undefined => {
|
return { upsert, listByChat };
|
||||||
const row = statements.findById.get(chatId, userId) as ChatMemberRow | undefined;
|
|
||||||
|
|
||||||
return row && toMember(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { upsert, listByChat, findById };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatMemberRepository = ReturnType<typeof createChatMemberRepository>;
|
export type ChatMemberRepository = ReturnType<typeof createChatMemberRepository>;
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ interface AssistantServiceDeps {
|
|||||||
|
|
||||||
const MAX_AGENT_STEPS = 6;
|
const MAX_AGENT_STEPS = 6;
|
||||||
|
|
||||||
/** Сообщение пользователя для модели: текст вопроса и, если модель умеет, картинки. */
|
/** Сообщение пользователя для модели: текст вопроса и скачанные картинки. */
|
||||||
const buildUserMessage = (question: Question, imageInput: boolean) => ({
|
const buildUserMessage = (question: Question) => ({
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: [
|
content: [
|
||||||
{ type: "text" as const, text: buildPrompt(question, imageInput) },
|
{ type: "text" as const, text: buildPrompt(question) },
|
||||||
...(imageInput
|
...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
|
||||||
? 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);
|
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),
|
instructions: buildInstructions(systemRules, question, persona),
|
||||||
requestContext,
|
requestContext,
|
||||||
maxSteps: MAX_AGENT_STEPS,
|
maxSteps: MAX_AGENT_STEPS,
|
||||||
@@ -68,7 +66,8 @@ export const createAssistantService = ({
|
|||||||
return result.text.trim();
|
return result.text.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
return { answer };
|
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
|
||||||
|
return { answer, canSeeImages: imageInput };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssistantService = ReturnType<typeof createAssistantService>;
|
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 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 =>
|
const activity = (member: ChatMember): string =>
|
||||||
member.lastSeen > 0
|
member.lastSeen > 0
|
||||||
? `сообщений: ${member.messageCount}, последнее: ${formatTime(member.lastSeen)} UTC`
|
? `сообщений в истории: ${member.messageCount}, последнее: ${formatUtc(member.lastSeen)}`
|
||||||
: "ещё не писал(а) при боте";
|
: "в сохранённой истории сообщений нет";
|
||||||
|
|
||||||
/** Участники в текстовом виде для контекста модели. */
|
/** Участники в текстовом виде для контекста модели. */
|
||||||
export const formatChatMembers = (members: ChatMember[]): string =>
|
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";
|
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 =>
|
export const formatChatMessages = (messages: ChatMessage[]): string =>
|
||||||
messages
|
messages
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const reply = m.replyToMessageId ? ` (ответ на #${m.replyToMessageId})` : "";
|
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");
|
.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 { formatChatMembers } from "./format-chat-members.js";
|
||||||
import { formatChatMessages } from "./format-chat-messages.js";
|
import { formatChatMessages } from "./format-chat-messages.js";
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ export interface Question {
|
|||||||
chatTitle: string;
|
chatTitle: string;
|
||||||
/** Как зовут спрашивающего: имя, фамилия и ник. */
|
/** Как зовут спрашивающего: имя, фамилия и ник. */
|
||||||
askerName: string;
|
askerName: string;
|
||||||
|
/** Может быть пустым, если прислали только картинку. */
|
||||||
text: string;
|
text: string;
|
||||||
/** Сообщение, на которое ответил спрашивающий, — сразу передаём его как контекст. */
|
/** Сообщение, на которое ответил спрашивающий, — сразу передаём его как контекст. */
|
||||||
repliedTo?: ChatMessage;
|
repliedTo?: ChatMessage;
|
||||||
@@ -14,20 +15,22 @@ export interface Question {
|
|||||||
mentionedMembers: ChatMember[];
|
mentionedMembers: ChatMember[];
|
||||||
/** Упомянутые ники, которых бот в чате не встречал. */
|
/** Упомянутые ники, которых бот в чате не встречал. */
|
||||||
unknownUsernames: string[];
|
unknownUsernames: string[];
|
||||||
/** Фото из вопроса и из сообщения, на которое ответили. */
|
/** Где в вопросе есть картинки — даже если модели их не отправляем. */
|
||||||
|
imageSources: ImageSource[];
|
||||||
|
/** Скачанные картинки для модели; пусто, если модель их не видит или скачать не вышло. */
|
||||||
images: ImageAttachment[];
|
images: ImageAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_SOURCES: Record<ImageAttachment["source"], string> = {
|
const IMAGE_SOURCES: Record<ImageSource, string> = {
|
||||||
question: "из вопроса",
|
question: "из вопроса",
|
||||||
replied: "из сообщения, на которое ответили",
|
replied: "из сообщения, на которое ответили",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
|
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
|
||||||
const imagesNote = (images: ImageAttachment[], canSeeImages: boolean): string | undefined => {
|
const imagesNote = ({ imageSources, images }: Question): string | undefined => {
|
||||||
if (images.length === 0) return undefined;
|
if (imageSources.length === 0) return undefined;
|
||||||
|
|
||||||
if (!canSeeImages) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
|
if (images.length === 0) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
|
||||||
|
|
||||||
return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`;
|
return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`;
|
||||||
};
|
};
|
||||||
@@ -54,12 +57,12 @@ export const buildInstructions = (systemRules: string, question: Question, perso
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
export const buildPrompt = (question: Question, canSeeImages: boolean): string => {
|
export const buildPrompt = (question: Question): string => {
|
||||||
const replied = question.repliedTo
|
const replied = question.repliedTo
|
||||||
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
|
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
|
||||||
: undefined;
|
: 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 { z } from "zod";
|
||||||
import type { ChatHistoryService } from "../../chat-history-service.js";
|
import type { ChatHistoryService } from "../../chat-history-service.js";
|
||||||
import type { ChatMemberService } from "../../chat-member-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 { formatChatMessages } from "../format-chat-messages.js";
|
||||||
import { assistantRequestContextSchema } from "./request-context.js";
|
import { assistantRequestContextSchema } from "./request-context.js";
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const createReadMemberMessagesTool = ({ chatHistoryService, chatMemberSer
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
member: memberName(found),
|
member: displayName(found),
|
||||||
count: messages.length,
|
count: messages.length,
|
||||||
result: messages.length > 0 ? formatChatMessages(messages) : "Сохранённых сообщений этого участника нет.",
|
result: messages.length > 0 ? formatChatMessages(messages) : "Сохранённых сообщений этого участника нет.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ interface ChatMemberServiceDeps {
|
|||||||
export interface MentionQuery {
|
export interface MentionQuery {
|
||||||
/** Ники из `@username` без собаки. */
|
/** Ники из `@username` без собаки. */
|
||||||
usernames: string[];
|
usernames: string[];
|
||||||
/** Пользователи из упоминаний по имени-ссылке (у них может не быть ника). */
|
/** ID людей, упомянутых по имени-ссылке (у них может не быть ника). */
|
||||||
profiles: MemberProfile[];
|
userIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMentions {
|
export interface ResolvedMentions {
|
||||||
@@ -37,21 +37,14 @@ const nameMatches = (query: string) => (member: ChatMember) => {
|
|||||||
.every((token) => memberWords.some((word) => word.startsWith(token)));
|
.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) => {
|
export const createChatMemberService = ({ chatMemberRepository }: ChatMemberServiceDeps) => {
|
||||||
/** Участник написал сообщение: обновляем имя, ник, счётчик и время активности. */
|
/** Запоминаем актуальные имя и ник: автор сообщения или человек, упомянутый по ссылке. */
|
||||||
const recordActivity = (chatId: number, profile: MemberProfile, date: number): void =>
|
const remember = (chatId: number, profile: MemberProfile): void => chatMemberRepository.upsert(chatId, profile);
|
||||||
chatMemberRepository.upsert(chatId, profile, { messages: 1, seenAt: date });
|
|
||||||
|
|
||||||
/** Запоминаем имя и ник без учёта активности (правка сообщения, упоминание по ссылке). */
|
|
||||||
const remember = (chatId: number, profile: MemberProfile): void =>
|
|
||||||
chatMemberRepository.upsert(chatId, profile, { messages: 0, seenAt: 0 });
|
|
||||||
|
|
||||||
|
/** Участники с активностью по сохранённой истории; сначала — недавно писавшие. */
|
||||||
const list = (chatId: number): ChatMember[] => chatMemberRepository.listByChat(chatId);
|
const list = (chatId: number): ChatMember[] => chatMemberRepository.listByChat(chatId);
|
||||||
|
|
||||||
/** Поиск по нику (с @ или без) или по имени/фамилии; сначала — недавно активные. */
|
/** Поиск по нику (с @ или без) или по имени/фамилии. */
|
||||||
const search = (chatId: number, query: string): ChatMember[] => {
|
const search = (chatId: number, query: string): ChatMember[] => {
|
||||||
const cleaned = query.trim().replace(/^@/, "");
|
const cleaned = query.trim().replace(/^@/, "");
|
||||||
|
|
||||||
@@ -64,24 +57,21 @@ export const createChatMemberService = ({ chatMemberRepository }: ChatMemberServ
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Превращает упоминания из вопроса в известных участников чата. */
|
/** Превращает упоминания из вопроса в известных участников чата. */
|
||||||
const resolveMentions = (chatId: number, { usernames, profiles }: MentionQuery): ResolvedMentions => {
|
const resolveMentions = (chatId: number, { usernames, userIds }: MentionQuery): ResolvedMentions => {
|
||||||
profiles.forEach((profile) => remember(chatId, profile));
|
if (usernames.length === 0 && userIds.length === 0) return { members: [], unknownUsernames: [] };
|
||||||
|
|
||||||
const members = list(chatId);
|
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
|
const mentioned = members.filter(
|
||||||
.map((profile) => chatMemberRepository.findById(chatId, profile.userId))
|
(member) => userIds.includes(member.userId) || usernames.some((username) => usernameIs(username)(member)),
|
||||||
.filter((member) => member !== undefined);
|
);
|
||||||
|
|
||||||
return {
|
return { members: mentioned, unknownUsernames };
|
||||||
members: uniqueByUser([...byUsername.flatMap(({ member }) => (member ? [member] : [])), ...byProfile]),
|
|
||||||
unknownUsernames: byUsername.filter(({ member }) => !member).map(({ username }) => username),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { recordActivity, remember, list, search, resolveMentions };
|
return { remember, list, search, resolveMentions };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatMemberService = ReturnType<typeof createChatMemberService>;
|
export type ChatMemberService = ReturnType<typeof createChatMemberService>;
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -20,23 +20,25 @@ export interface ChatMessage {
|
|||||||
|
|
||||||
/** Участник чата, которого бот видел: писал сам или был упомянут по имени-ссылке. */
|
/** Участник чата, которого бот видел: писал сам или был упомянут по имени-ссылке. */
|
||||||
export interface ChatMember {
|
export interface ChatMember {
|
||||||
chatId: number;
|
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
/** Сколько его сообщений в сохранённой истории чата. */
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
/** Unix-время последнего сообщения; 0 — ещё не писал (только упоминали). */
|
/** Unix-время последнего сохранённого сообщения; 0 — сообщений в истории нет. */
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
|
/** Данные пользователя Telegram, которые бот запоминает об участнике. */
|
||||||
export type MemberProfile = Pick<ChatMember, "userId" | "username" | "firstName" | "lastName">;
|
export type MemberProfile = Pick<ChatMember, "userId" | "username" | "firstName" | "lastName">;
|
||||||
|
|
||||||
|
/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */
|
||||||
|
export type ImageSource = "question" | "replied";
|
||||||
|
|
||||||
/** Картинка, которую бот передаёт модели вместе с вопросом. */
|
/** Картинка, которую бот передаёт модели вместе с вопросом. */
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
/** Откуда фото: из самого вопроса или из сообщения, на которое ответил спрашивающий. */
|
source: ImageSource;
|
||||||
source: "question" | "replied";
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user