This commit is contained in:
Raphael Elita 2025-07-24 21:01:46 +02:00
parent 3b0d281844
commit ca13019afb
27 changed files with 19861 additions and 0 deletions

141
README.md
View File

@ -0,0 +1,141 @@
Вот **аннотацию и инструкцию для README.md** для твоего проекта **BotRouting** — всё по делу, без воды и с пояснением структуры, установки и запуска.
---
# **BotRouting**
**BotRouting** — это гибкая система маршрутизации и управления Telegram-ботами с поддержкой:
* разделения прав пользователей по группам и внешним интерфейсам,
* гибкой маршрутизации сообщений (по ключевым словам, командам, LLM),
* современной админкой для управления пользователями, группами, ботами, командами,
* масштабируемой архитектуры на Node.js, Next.js и Prisma.
## **Структура репозитория**
```
BotRouting/
├── admin/ # Админ-панель (Next.js + React)
├── deamon/ # Демон-роутер (Node.js, Telegram, LLM, Prisma)
├── clientExample/# Пример клиента (необязательно)
├── node_modules/
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── README.md
```
* **admin/** — современная админка для управления группами, пользователями, ботами, командами.
* **deamon/** — основной роутинг-сервис: интеграция Telegram API, LLM, Prisma.
* **clientExample/** — пример использования клиента (опционально, можешь удалить).
---
## **Установка**
### 1. **Клонируй репозиторий**
```sh
git clone <URL-репозитория>
cd BotRouting
```
### 2. **Установи зависимости**
> Можно использовать **pnpm** (предпочтительно) или **npm**оба поддерживаются благодаря workspace-конфигу.
#### **Через pnpm:**
```sh
pnpm install
```
#### **Через npm:**
```sh
npm install
```
### 3. **Настрой переменные окружения**
Создай файл `.env` в корне каждого пакета (`admin/`, `deamon/`) и задай переменные:
#### **Пример для `deamon/.env`:**
```
DATABASE_URL="file:../deamon/dev.db"
TELEGRAM_TOKEN="...твой токен..."
OPENAI_API_KEY="...ключ openai..."
LM_MODEL="vikhrmodels-vikhr-nemo-12b-instruct-r-21-09-24"
LM_BASE_URL="http://localhost:1234/v1"
```
#### **Пример для `admin/.env`:**
```
DATABASE_URL="file:../deamon/dev.db"
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
```
> **Важно:** один и тот же файл БД может использоваться обеими частями (админка и демон), если база в SQLite и тестовая. Для production — смотри документацию Prisma!
---
### 4. **Инициализируй и мигрируй БД**
```sh
pnpm -F deamon prisma migrate dev
```
или
```sh
cd deamon
npx prisma migrate dev
```
---
## **Запуск**
### **Админ-панель**
```sh
pnpm -F admin dev
```
или
```sh
cd admin
npm run dev
```
Откроется на [http://localhost:3000](http://localhost:3000)
---
### **Демон-роутер (боты и LLM)**
```sh
pnpm -F deamon start
```
или
```sh
cd deamon
node external-bot-daemon.js
```
---
## **Основные возможности**
* **Управление доступом**: через группы, ботов и команды.
* **Гибкая маршрутизация сообщений**: по ключевым словам, командам или LLM (AI).
* **LLM-интеграция**: поддержка локальных или облачных моделей через OpenAI API (в т.ч. open-source локальные, если указан LM\_BASE\_URL).
* **Модульная архитектура**: легко добавлять новые типы ботов, логику, интерфейсы и расширять систему.
---

1
admin Submodule

@ -0,0 +1 @@
Subproject commit 27ef38e41bea642bcaa837082e77b6c8db14cb6b

View File

@ -0,0 +1,131 @@
// RmqTelegramBot.js
import TelegramBot from 'node-telegram-bot-api'
import { v4 as uuidv4 } from 'uuid'
import { getChannel } from './utils/rmq.js'
export class RmqTelegramBot {
constructor(botName, token, options = {}) {
this._bot = new TelegramBot(token, { ...options, polling: false, webHook: false, debug: true })
this._name = botName
this._channel = getChannel()
this._inQ = `InMessage${botName}`
this._outQ = `OutMessage${botName}`
this._sessionQ = `SessionQueue${botName}` // общая системная очередь для сессий
// убедимся, что очередь существует
this._channel.assertQueue(this._sessionQ, { durable: true })
// переопределяем HTTPзапрос, чтобы шлём всё в OutMessage…
this._bot._request = (type, form) => {
const payload = { method: type, form }
this._channel.sendToQueue(
this._outQ,
Buffer.from(JSON.stringify(payload)),
{ persistent: true }
)
}
// InMessage → processUpdate
this._channel.consume(this._inQ, msg => {
try {
const { update } = JSON.parse(msg.content.toString())
console.log('update',update);
this._bot.processUpdate(update)
this._channel.ack(msg)
} catch (err) {
console.error('Failed to process update from RMQ', err)
this._channel.nack(msg, false, false)
}
})
// Proxy для автоматической делегации любых методов в this._bot
return new Proxy(this, {
get(target, prop) {
if (prop in target) {
const v = target[prop]
return typeof v === 'function' ? v.bind(target) : v
}
if (prop in target._bot) {
const v = target._bot[prop]
return typeof v === 'function' ? v.bind(target._bot) : v
}
throw new Error(`No such method or property: ${String(prop)}`)
}
})
}
// ——————— Сессионные методы ———————
/**
* RPCзапрос текущего занятия сессии
* @param {string} userId
* @returns {Promise<object>} состояние из sessionсервиса
*/
async requestSessionState(userId) {
const correlationId = uuidv4()
// временная очередь-ответчик
const { queue: replyQ } = await this._channel.assertQueue('', { exclusive: true })
return new Promise((resolve, reject) => {
this._channel.consume(replyQ, msg => {
if (msg.properties.correlationId === correlationId) {
const state = JSON.parse(msg.content.toString())
resolve(state)
}
}, { noAck: true })
// запрос в общий SessionQueue
const payload = { action: 'get', userId }
this._channel.sendToQueue(
this._sessionQ,
Buffer.from(JSON.stringify(payload)),
{ correlationId, replyTo: replyQ }
)
})
}
/**
* Захватить внимание этого бота за пользователем
* @param {string} userId
*/
lockSession(userId) {
const payload = {
action: 'lock',
userId,
externalBotId: this._name
}
this._channel.sendToQueue(
this._sessionQ,
Buffer.from(JSON.stringify(payload)),
{ persistent: true }
)
}
/**
* Отпустить внимание (снять блокировку)
* @param {string} userId
*/
unlockSession(userId) {
const payload = { action: 'unlock', userId }
this._channel.sendToQueue(
this._sessionQ,
Buffer.from(JSON.stringify(payload)),
{ persistent: true }
)
}
// ——————— Событийные обёртки ———————
on(event, listener) {
this._bot.on(event, listener)
return this
}
once(event, listener) {
this._bot.once(event, listener)
return this
}
removeListener(event, listener) {
this._bot.removeListener(event, listener)
return this
}
}

33
clientExample/index.js Normal file
View File

@ -0,0 +1,33 @@
import { RmqTelegramBot } from './RmqTelegramBot.js'
// Предположим, что ваш ext.id и имя бота — 'BusinessTrips'
const botName = 'BusinessTrips'
const botToken = еважен_здесь'; // нужен лишь для инициализации, но API не вызывается
import { initRabbit, getChannel } from './utils/rmq.js';
async function main() {
const channel = await initRabbit(process.env.RABBIT_URL || 'amqp://telegram_main:PASSWORD@rabbit.vidi.one:5672/telegram_main');
const bot = new RmqTelegramBot(botName, botToken)
// регистрируем обработчик апдейтов «как обычно»
bot.on('message', async msg => {
console.log('message',msg);
// const resp = await bot.requestSessionState(msg.innerUserId);
// console.log('resp',resp);
// bot.lockSession(msg.innerUserId);
bot.sendMessage(
msg.chat.id,
'Тест окей'
)
})
// ваш сервис не вызывает bot.launch() и не запускает polling/webhook
console.log(`🚀 RMQbot for ${botName} ready, listening InMessage${botName}`)
}
main().catch(console.error)

2397
clientExample/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"name": "clientexample",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"amqplib": "^0.10.8",
"node-telegram-bot-api": "^0.66.0"
}
}

View File

@ -0,0 +1,37 @@
Ты выступаешь в роли маршрутизатора сообщений между различными подботами в Telegram.
На входе ты получаешь только текст сообщения от пользователя.
Твоя задача — определить, какому из подботов следует передать сообщение. Ответ строго в формате JSON:
{
"bot": "название_бота",
"reason": "короткое объяснение маршрута"
}
Вот доступные подботы и их специализация:
- "trip_bot": Вопросы о командировках, поездках, бронировании отелей.
- "weather_bot": Прогноз погоды, текущая температура, осадки и т.п.
- "news_bot": Новости мира, страны, региона.
- "joke_bot": Пошути, расскажи анекдот, интересный факт.
- "support_bot": Техническая поддержка, вопросы о работе системы.
- "finance_bot": Курс валют, инвестиции, финансы, банки.
Если сообщение не удаётся однозначно отнести ни к одному боту, укажи "bot": "unknown".
Примеры:
Вход: "Какая погода завтра в Белграде?"
Ответ:
{
"bot": "weather_bot",
"reason": "Запрос прогноза погоды на завтра в конкретном городе"
}
Вход: "Забронируй мне командировку в Москву с понедельника"
Ответ:
{
"bot": "trip_bot",
"reason": "Пользователь оформляет командировку"
}

View File

@ -0,0 +1,49 @@
/**
* Генерирует системный промт для LLM-роутинга
* @param {Array} bots - [{ name: 'trip_bot', description: '...' }, ...]
* @returns {string}
*/
export function buildRouterPrompt(bots) {
console.log('bots',bots);
// Преобразуем список ботов в текст с описаниями
const botsBlock = bots.map(
b => `- "${b.name}": ${b.description || 'Без описания.'}`
).join('\n')
// Примеры (можно вынести отдельно)
const examples = `
Вход: "Какая погода завтра в Белграде?"
Ответ:
{
"bot": "weather_bot",
"reason": "Запрос прогноза погоды на завтра в конкретном городе"
}
Вход: "Забронируй мне командировку в Москву с понедельника"
Ответ:
{
"bot": "trip_bot",
"reason": "Пользователь оформляет командировку"
}`.trim()
return `
Ты выступаешь в роли маршрутизатора сообщений между различными подботами в Telegram.
На входе ты получаешь только текст сообщения от пользователя.
Твоя задача определить, какому из подботов следует передать сообщение. Ответ строго в формате JSON:
{
"bot": азвание_бота",
"reason": "короткое объяснение маршрута"
}
Вот доступные подботы и их специализация в формаьте (- {name}: {description}):
${botsBlock}
Если сообщение не удаётся однозначно отнести ни к одному боту или нет бота способного обработать это исключение, укажи "bot": "unknown".
`.trim()
}

View File

@ -0,0 +1,14 @@
// rmq.js
import amqplib from 'amqplib';
let channel;
export async function initRabbit(url) {
const conn = await amqplib.connect(url);
channel = await conn.createChannel();
return channel;
}
export function getChannel() {
if (!channel) throw new Error('RabbitMQ channel not initialized');
return channel;
}

View File

@ -0,0 +1,67 @@
// external-bot-daemon.js
import TelegramBot from 'node-telegram-bot-api'
import { PrismaClient } from '@prisma/client'
import dotenv from 'dotenv'
dotenv.config()
// ...твой код ниже
const prisma = new PrismaClient()
// Мапа для хранения всех активных клиентов
const bots = {}
async function main() {
// Получаем все внешние интерфейсы из базы
const externalBots = await prisma.externalBot.findMany({
include: { bots: { include: { commands: { include: { groupIds: true } }, groups: true } } }
})
// Для каждого интерфейса запускаем TelegramBot
for (const ext of externalBots) {
if (!ext.token) continue
// Если уже создан, не пересоздаем
if (bots[ext.token]) continue
console.log('ext.token',ext.token);
const bot = new TelegramBot(ext.token, { polling: true })
bots[ext.token] = bot
bot.on('message', async msg => {
// user id
const userId = msg.from.id.toString()
// Можно искать User по platform/ID (если надо)
// Получаем все боты и команды для этого интерфейса
const freshExt = await prisma.externalBot.findUnique({
where: { id: ext.id },
include: { bots: { include: { commands: { include: { groupIds: true } }, groups: true } } }
})
// Можно получить все команды и группы
const routing = freshExt.bots.map(b => ({
name: b.name,
commands: b.commands.map(cmd => ({
command: cmd.command,
description: cmd.description,
groups: cmd.groupIds.map(g => g.name)
})),
groups: b.groups.map(g => g.name)
}))
// Для теста просто отправь список команд в ответ
const text =
routing.map(b =>
`Бот: ${b.name}\nКоманды:\n` +
b.commands.map(c =>
` /${c.command} (${c.groups.join(', ')})${c.description ? ': ' + c.description : ''}`
).join('\n')
).join('\n\n')
bot.sendMessage(msg.chat.id, text || 'Нет доступных команд')
})
console.log(`Started polling for ExternalBot "${ext.name}"`)
}
}
main().catch(console.error)

View File

@ -0,0 +1,103 @@
import TelegramBot from 'node-telegram-bot-api'
import { PrismaClient } from '@prisma/client'
import dotenv from 'dotenv'
import { handleIncomingMessage } from './router/index.js'
import { initRabbit, getChannel } from './utils/rmq.js';
import { getSessionState,lockSessionToBot,unlockSession } from './router/session.js'
dotenv.config()
const prisma = new PrismaClient()
const bots = {}
async function main() {
const channel = await initRabbit(process.env.RABBIT_URL || 'amqp://telegram_main:PASSWORD@rabbit.vidi.one:5672/telegram_main');
const externalBots = await prisma.externalBot.findMany({
include: { bots: { include: { commands: { include: { groupIds: true } }, groups: true } } }
})
// сначала ассерт очередей и потребитель OutMessage…
for (const ext of externalBots) {
for( const bot of ext.bots){
const inQ = `InMessage${bot.name}`;
const outQ = `OutMessage${bot.name}`;
const sessionQ = `SessionQueue${bot.name}`;
await channel.assertQueue(inQ, { durable: true });
await channel.assertQueue(outQ, { durable: true });
await channel.assertQueue(sessionQ, { durable: true });
channel.consume(outQ, msg => {
const { method,form } = JSON.parse(msg.content.toString());
// Низкоуровневый вызов HTTP-запроса к Telegram API
bots[ext.token]._request(method,form);
channel.ack(msg);
});
channel.consume(sessionQ, async reqMsg => {
try {
const { action, userId } = JSON.parse(reqMsg.content.toString());
// внешний бот, привязанный к этой очереди
const externalBotId = ext.id;
switch (action) {
case 'get': {
// узнаём, захватил ли кто-то сессию
const target = await getSessionState(userId, externalBotId);
const resp = JSON.stringify({ externalBotId: target });
channel.sendToQueue(
reqMsg.properties.replyTo,
Buffer.from(resp),
{ correlationId: reqMsg.properties.correlationId }
);
break;
}
case 'lock': {
// этот бот берёт сессию
await lockSessionToBot(userId, externalBotId, bot.id);
break;
}
case 'unlock': {
// этот бот снимает свою блокировку
await unlockSession(userId, externalBotId);
break;
}
default:
console.warn(`Unknown session action: ${action}`);
}
channel.ack(reqMsg);
} catch (err) {
console.error('Error processing sessionQ message', err);
// не возвращаем в очередь
channel.nack(reqMsg, false, false);
}
});
}
}
// теперь создаём и запускаем Telegramботов
for (const ext of externalBots) {
if (!ext.token || bots[ext.token]) continue;
const bot = new TelegramBot(ext.token, { polling: true, debug: true });
bots[ext.token] = bot;
bot.processUpdate = async (update) => {
const ctx = { update, ext, bot, prisma, allBots: externalBots };
console.log('handleIncomingMessage');
await handleIncomingMessage(ctx);
}
// bot..on('message', async msg => {
//
// await handleIncomingMessage(ctx);
// });
console.log(`Started polling for ExternalBot "${ext.name}"`);
}
}
main().catch(console.error)

29
deamon/groupFilter.js Normal file
View File

@ -0,0 +1,29 @@
// groupFilter.js
// Есть ли хотя бы одна общая группа (userGroups и otherGroups — массивы объектов с .id)
export function hasCommonGroup(userGroups, otherGroups) {
if (!Array.isArray(userGroups) || !Array.isArray(otherGroups)) return false
const userGroupIds = userGroups.map(g => g.id)
return otherGroups.some(g => userGroupIds.includes(g.id))
}
// Строго фильтрует: возвращает только ботов, у которых есть общая группа с userGroups,
// и только те команды внутри бота, которые разрешены этим группам.
export function filterBotsAndCommandsByUserGroups(allBots, userGroups) {
console.log('allBots');
console.dir(allBots, { depth: null, colors: true });
return allBots
.filter(bot => {
const botGroups = bot.groups || []
// Строго: если у бота есть группы — только с пересечением, иначе вообще не показываем
return botGroups.length > 0 && hasCommonGroup(userGroups, botGroups)
})
.map(bot => {
// Оставляем только доступные команды для этих групп
const filteredCommands = (bot.commands || []).filter(cmd =>
cmd.groupIds?.length > 0 && hasCommonGroup(userGroups, cmd.groupIds)
)
return { ...bot, commands: filteredCommands }
})
}

18
deamon/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "deamon",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "external-bot-daemon.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@prisma/client": "^6.12.0",
"amqplib": "^0.10.8",
"node-telegram-bot-api": "^0.66.0",
"openai": "^5.10.2"
}
}

View File

@ -0,0 +1,28 @@
// commandMatcher.js
/**
* Ищет команду в начале текста и возвращает объект с ботом и командой
* @param {string} text
* @param {Array<{ name: string, commands: Array<{ command: string }> }>} bots
* @returns {{ botName: string, commandName: string } | null}
*/
export function findCommandTargetBot(text, bots) {
if (!text) return null
// первое слово, без ведущего "/"
const [firstWord] = text.trim().split(/\s+/)
const normalized = firstWord.replace(/^\//, '').toLowerCase()
for (const b of bots) {
for (const cmd of b.commands || []) {
if (cmd.command.toLowerCase() === normalized) {
return {
botName: b.name,
commandName: cmd.command
}
}
}
}
return null
}

156
deamon/router/index.js Normal file
View File

@ -0,0 +1,156 @@
import { getLockingBot } from './session.js'
import { findCommandTargetBot } from './commandMatcher.js'
import { routeByLLM } from './llmRouter.js'
import { getOrCreateUserAndGroups } from '../userService.js'
import { filterBotsAndCommandsByUserGroups } from '../groupFilter.js'
import { checkUserBotRights, checkUserCommandRights } from './rights.js'
import { getChannel } from '../utils/rmq.js' // <-- импорт канала
const updateKeys = [
'message',
'edited_message',
'channel_post',
'edited_channel_post',
'business_connection',
'business_message',
'edited_business_message',
'deleted_business_messages',
'message_reaction',
'message_reaction_count',
'inline_query',
'chosen_inline_result',
'callback_query',
'shipping_query',
'pre_checkout_query',
'purchased_paid_media',
'poll',
'poll_answer',
'my_chat_member',
'chat_member',
'chat_join_request',
'chat_boost',
'removed_chat_boost'
];
export async function handleIncomingMessage(ctx) {
//console.log('ctx',ctx);
const { update, ext, prisma } = ctx
const foundKey = updateKeys.find(key => update[key] !== undefined);
const msg = update[foundKey];
const channel = getChannel()
// 1) Пользователь и группы
const { user, userId, groups } = await getOrCreateUserAndGroups(ctx.prisma, msg.from)
msg['innerUserId'] = userId;
console.log('ext',ext);
// 0) Подтягиваем актуальный externalBot вместе с bots, commands и groups
const fullExt = await prisma.externalBot.findUnique({
where: { id: ext.id },
include: {
bots: {
include: {
commands: { include: { groupIds: true } },
groups: true
}
}
}
});
console.log('fullExt',fullExt);
if (!fullExt) {
return ctx.bot.sendMessage(msg.chat.id, 'Internal error: external bot not found')
}
// 2) Доступные боты
const availableBots = filterBotsAndCommandsByUserGroups(fullExt.bots, groups)
.filter(b => b.isActive)
console.log('availableBots',availableBots);
// 3) Захват внимания в сессии
const sessionBotID = await getLockingBot(userId,ext.id);
console.log('sessionBotID',sessionBotID);
if (sessionBotID) {
const sessionBotName = (availableBots.find(b => b.id === sessionBotID) || {}).name || null
if(sessionBotName){
if (!checkUserBotRights(availableBots, sessionBotName)) {
return ctx.bot.sendMessage(msg.chat.id, 'Нет доступа к боту.')
}
// Публикуем в очередь
const inQ = `InMessage${sessionBotName}`
channel.sendToQueue(inQ, Buffer.from(JSON.stringify({
update,
userId,
reason: 'session_capture'
})), { persistent: true })
return
}else{
await unlockSession(userId,ext.id)
return ctx.bot.sendMessage(msg.chat.id, 'Нет доступа к выбранному боту.')
}
}
// 4) Чёткая команда
if(msg?.text){
const commandMatch = findCommandTargetBot(msg.text, availableBots)
if (commandMatch) {
const { botName, commandName } = commandMatch
if (!checkUserCommandRights(availableBots, botName, commandName)) {
return ctx.bot.sendMessage(msg.chat.id, 'Нет доступа к команде.')
}
const inQ = `InMessage${botName}`
channel.sendToQueue(inQ, Buffer.from(JSON.stringify({
update,
userId,
command: commandName,
reason: 'explicit_command'
})), { persistent: true })
return
}
// 5) LLMроутинг
const llmInput = availableBots.map(b => ({
name: b.name,
description: b.description || 'Без описания'
}))
const llmResult = await routeByLLM(msg.text, llmInput)
const targetBot = llmResult.bot
if (targetBot && targetBot !== 'unknown') {
if (!checkUserBotRights(availableBots, targetBot)) {
return ctx.bot.sendMessage(msg.chat.id, 'Нет доступа к выбранному боту.')
}
const inQ = `InMessage${targetBot}`
const ok = channel.sendToQueue(inQ, Buffer.from(JSON.stringify({
update,
userId,
reason: llmResult.reason
})), { persistent: true })
if (!ok) {
// буфер переполнен, подождём drain
channel.once('drain', () => {
console.log('Канал освободился, можно отправлять дальше')
})
}else{
console.log('Отправили в ',inQ)
}
return
}
// 6) Не смогли определить бота — уведомляем сразу
return ctx.bot.sendMessage(
msg.chat.id,
'Не удалось определить, какому боту передать сообщение.'
)
}
}

View File

@ -0,0 +1,50 @@
import OpenAI from 'openai';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'test';
const LM_BASE_URL = process.env.LM_BASE_URL || 'http://localhost:1234/v1';
export const openai = new OpenAI({
baseURL: LM_BASE_URL,
apiKey: OPENAI_API_KEY,
});
import {buildRouterPrompt} from '../utils/llmUtils.js'
/**
* Маршрутизация через LLM: возвращает { bot, reason }
* @param {string} userText
* @param {Array} bots - [{ name, description }]
* @returns {Promise<{bot: string, reason: string}>}
*/
export async function routeByLLM(userText, bots) {
const prompt = buildRouterPrompt(bots);
console.log('prompt',prompt);
const messages = [
{ role: 'system', content: prompt },
{ role: 'user', content: userText }
]
console.log('messages',messages[1]);
const data = {
model: process.env.LM_MODEL || 'vikhrmodels-vikhr-nemo-12b-instruct-r-21-09-24',
messages
}
const response = await openai.chat.completions.create(data)
const content = response.choices[0]?.message?.content || '{}'
console.log('content',content);
let jsonString = content
.replace(/```(?:json)?\s*/g, '') // убираем ``` или ```json и любые пробелы/переводы строки после
.replace(/```/g, '') // убираем оставшиеся ```
.trim();
try {
const result = JSON.parse(jsonString)
// result: { bot, reason }
return result
} catch (e) {
return { bot: 'unknown', reason: 'Ошибка парсинга ответа' }
}
}

23
deamon/router/rights.js Normal file
View File

@ -0,0 +1,23 @@
// rights.js
/**
* Проверка, есть ли у пользователя доступ к боту по его имени
* @param {Array<Object>} availableBots массив объектов ботов { name, commands, }
* @param {string} botName название бота для проверки
* @returns {boolean}
*/
export function checkUserBotRights(availableBots, botName) {
return availableBots.some(b => b.name === botName);
}
/**
* Проверка, есть ли у пользователя доступ к конкретной команде бота
* @param {Array<Object>} availableBots массив объектов ботов { name, commands, }
* @param {string} botName название бота
* @param {string} commandName команда (поле `command`) для проверки
* @returns {boolean}
*/
export function checkUserCommandRights(availableBots, botName, commandName) {
const bot = availableBots.find(b => b.name === botName);
return !!(bot && bot.commands.some(cmd => cmd.command === commandName));
}

14
deamon/router/rmq.js Normal file
View File

@ -0,0 +1,14 @@
// rmq.js
import amqplib from 'amqplib';
let channel;
export async function initRabbit(url) {
const conn = await amqplib.connect(url);
channel = await conn.createChannel();
return channel;
}
export function getChannel() {
if (!channel) throw new Error('RabbitMQ channel not initialized');
return channel;
}

85
deamon/router/session.js Normal file
View File

@ -0,0 +1,85 @@
// session.js
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
/**
* Получить запись ChatState для пары (userId, externalBotId).
* @param {string} userId
* @param {string} externalBotId
* @returns {Promise<import('@prisma/client').ChatState|null>}
*/
export async function getSessionState(userId, externalBotId) {
return prisma.chatState.findUnique({
where: {
userId_externalBotId: { userId, externalBotId }
}
})
}
/**
* Узнать, какой внутренний бот сейчас держит сессию.
* @param {string} userId
* @param {string} externalBotId
* @returns {Promise<string|null>} возвращает botId или null
*/
export async function getLockingBot(userId, externalBotId) {
const state = await getSessionState(userId, externalBotId)
return state ? state.lockedByBot : null
}
/**
* Захватить внимание пользователя этим внутренним ботом.
* Запишет в lockedByBot = botId.
* @param {string} userId
* @param {string} externalBotId
* @param {string} botId id внутреннего Bot
*/
export async function lockSessionToBot(userId, externalBotId, botId) {
await prisma.chatState.upsert({
where: {
userId_externalBotId: { userId, externalBotId }
},
update: {
lockedByBot: botId,
updatedAt: new Date()
},
create: {
userId,
externalBotId,
stateJson: {},
lockedByBot: botId
}
})
}
/**
* Освободить сессию у пользователя для данного внешнего бота.
* Запишет lockedByBot = null.
* @param {string} userId
* @param {string} externalBotId
*/
export async function unlockSession(userId, externalBotId) {
await prisma.chatState.updateMany({
where: {
userId,
externalBotId,
// только если была блокировка
lockedByBot: { not: null }
},
data: {
lockedByBot: null
}
})
}
/**
* Проверить, захватил ли сессию конкретный внутренний бот.
* @param {string} userId
* @param {string} externalBotId
* @param {string} botId
* @returns {Promise<boolean>}
*/
export async function isSessionLockedByBot(userId, externalBotId, botId) {
const state = await getSessionState(userId, externalBotId)
return !!state && state.lockedByBot === botId
}

36
deamon/userService.js Normal file
View File

@ -0,0 +1,36 @@
// userService.js
export async function getOrCreateUserAndGroups(prisma, fromInfo) {
const platform = 'telegram'
const originalPlatformId = fromInfo.id.toString()
const username = fromInfo.username || ''
// 1. Пытаемся найти пользователя по уникальному ключу (originalPlatformId + platform)
let user = await prisma.user.findUnique({
where: {
platform_originalPlatformId: { platform, originalPlatformId }
},
include: { groups: true } // <-- many-to-many!
})
// 2. Если не найден — создаём
if (!user) {
user = await prisma.user.create({
data: {
username,
platform,
originalPlatformId
},
include: { groups: true }
})
}
// 3. Получаем id пользователя
const userId = user.id
// 4. Массив групп пользователя
const groups = user.groups ?? []
// 5. Возвращаем user, userId, groups
return { user, userId, groups }
}

View File

@ -0,0 +1,37 @@
Ты выступаешь в роли маршрутизатора сообщений между различными подботами в Telegram.
На входе ты получаешь только текст сообщения от пользователя.
Твоя задача — определить, какому из подботов следует передать сообщение. Ответ строго в формате JSON:
{
"bot": "название_бота",
"reason": "короткое объяснение маршрута"
}
Вот доступные подботы и их специализация:
- "trip_bot": Вопросы о командировках, поездках, бронировании отелей.
- "weather_bot": Прогноз погоды, текущая температура, осадки и т.п.
- "news_bot": Новости мира, страны, региона.
- "joke_bot": Пошути, расскажи анекдот, интересный факт.
- "support_bot": Техническая поддержка, вопросы о работе системы.
- "finance_bot": Курс валют, инвестиции, финансы, банки.
Если сообщение не удаётся однозначно отнести ни к одному боту, укажи "bot": "unknown".
Примеры:
Вход: "Какая погода завтра в Белграде?"
Ответ:
{
"bot": "weather_bot",
"reason": "Запрос прогноза погоды на завтра в конкретном городе"
}
Вход: "Забронируй мне командировку в Москву с понедельника"
Ответ:
{
"bot": "trip_bot",
"reason": "Пользователь оформляет командировку"
}

49
deamon/utils/llmUtils.js Normal file
View File

@ -0,0 +1,49 @@
/**
* Генерирует системный промт для LLM-роутинга
* @param {Array} bots - [{ name: 'trip_bot', description: '...' }, ...]
* @returns {string}
*/
export function buildRouterPrompt(bots) {
console.log('bots',bots);
// Преобразуем список ботов в текст с описаниями
const botsBlock = bots.map(
b => `- "${b.name}": ${b.description || 'Без описания.'}`
).join('\n')
// Примеры (можно вынести отдельно)
const examples = `
Вход: "Какая погода завтра в Белграде?"
Ответ:
{
"bot": "weather_bot",
"reason": "Запрос прогноза погоды на завтра в конкретном городе"
}
Вход: "Забронируй мне командировку в Москву с понедельника"
Ответ:
{
"bot": "trip_bot",
"reason": "Пользователь оформляет командировку"
}`.trim()
return `
Ты выступаешь в роли маршрутизатора сообщений между различными подботами в Telegram.
На входе ты получаешь только текст сообщения от пользователя.
Твоя задача определить, какому из подботов следует передать сообщение. Ответ строго в формате JSON:
{
"bot": азвание_бота",
"reason": "короткое объяснение маршрута"
}
Вот доступные подботы и их специализация в формаьте (- {name}: {description}):
${botsBlock}
Если сообщение не удаётся однозначно отнести ни к одному боту или нет бота способного обработать это исключение, укажи "bot": "unknown".
`.trim()
}

14
deamon/utils/rmq.js Normal file
View File

@ -0,0 +1,14 @@
// rmq.js
import amqplib from 'amqplib';
let channel;
export async function initRabbit(url) {
const conn = await amqplib.connect(url);
channel = await conn.createChannel();
return channel;
}
export function getChannel() {
if (!channel) throw new Error('RabbitMQ channel not initialized');
return channel;
}

9609
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"private": true,
"workspaces": [
"admin",
"deamon"
],
"dependencies": {
"amqplib": "^0.10.8",
"dotenv": "^17.2.0",
"node-telegram-bot-api": "^0.66.0"
}
}

6708
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
packages:
- 'admin'
- 'deamon'