INIT
This commit is contained in:
parent
3b0d281844
commit
ca13019afb
141
README.md
141
README.md
|
|
@ -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).
|
||||
* **Модульная архитектура**: легко добавлять новые типы ботов, логику, интерфейсы и расширять систему.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 27ef38e41bea642bcaa837082e77b6c8db14cb6b
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(`🚀 RMQ‑bot for ${botName} ready, listening InMessage${botName}`)
|
||||
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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": "Пользователь оформляет командировку"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
'Не удалось определить, какому боту передать сообщение.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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: 'Ошибка парсинга ответа' }
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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": "Пользователь оформляет командировку"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,4 @@
|
|||
packages:
|
||||
- 'admin'
|
||||
- 'deamon'
|
||||
|
||||
Loading…
Reference in New Issue