Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions instance-manager/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Dependencies
/node_modules

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-temporary-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional eslint cache
.eslintcache

# dotenv environment variables file
.env

# SQLite database file
database.sqlite
*.sqlite
*.sqlite-journal
64 changes: 64 additions & 0 deletions instance-manager/controllers/client.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// =================================================================
// Файл: controllers/client.controller.js
// Описание: Контроллер для обработки логики, связанной с клиентами.
// =================================================================
const Client = require('../../database/models/client.model');
const Instance = require('../../database/models/instance.model');
const greenApiService = require('../../services/greenApi.service');

const getClientStatus = async (req, res, next) => {
const { portal_url } = req.body;

if (!portal_url) {
return res.status(400).json({ error: true, message: 'portal_url является обязательным полем.' });
}

try {
const client = await Client.findOne({
where: { portal_url },
include: [{ model: Instance, as: 'instances' }]
});

if (!client) {
return res.status(200).json({
client_exists: false,
instances: []
});
}

// Обновляем статусы инстансов "на лету"
const instancesWithStatus = await Promise.all(
client.instances.map(async (instance) => {
const decryptedToken = instance.getDecryptedToken();
const currentStatus = await greenApiService.getStateInstance(instance.idInstance, decryptedToken);

// Опционально: можно обновлять статус и номер телефона в БД, если они изменились
if (currentStatus !== instance.status && currentStatus !== 'error') {
instance.status = currentStatus;
// Здесь можно добавить логику получения номера телефона, если статус стал 'authorized'
await instance.save();
}

return {
id: instance.id,
idInstance: instance.idInstance,
name: instance.name,
phone_number: instance.phone_number,
status: currentStatus
};
})
);

res.status(200).json({
client_exists: true,
instances: instancesWithStatus
});

} catch (error) {
next(error);
}
};

module.exports = {
getClientStatus
};
100 changes: 100 additions & 0 deletions instance-manager/controllers/instance.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// =================================================================
// Файл: controllers/instance.controller.js
// Описание: Контроллер для логики создания инстансов.
// =================================================================
const Client = require('../../database/models/client.model');
const Instance = require('../../database/models/instance.model');
const greenApiService = require('../../services/greenApi.service');

const createInstance = async (req, res, next) => {
const { portal_url, member_id, name } = req.body;

if (!portal_url || !member_id) {
return res.status(400).json({ error: true, message: 'portal_url и member_id являются обязательными полями.' });
}

try {
// 1. Найти или создать клиента
const [client, created] = await Client.findOrCreate({
where: { portal_url },
defaults: { member_id, portal_url }
});

// 2. Определить URL для вебхуков. Он должен быть известен до создания инстанса.
// Мы будем использовать ID клиента и временную метку для уникальности,
// а реальный idInstance подставим позже, когда получим его.
// Но для API GreenAPI нужен сразу готовый URL. Поэтому мы создадим его на основе будущего ID инстанса.
// Это предсказание не идеально, но для данной логики подходит.
// Лучше было бы иметь возможность обновить webhook после создания.
// Так как API позволяет задать его при создании, воспользуемся этим.

// Сначала создадим запись в БД, чтобы получить newInstance.id
const tempInstanceName = name || `Инстанс #${client.id}-${Date.now()}`;

// 2. Выполнить запрос к Green API на создание инстанса
// Сначала подготовим URL для вебхука. Green API требует его при создании.
// Мы передадим все настройки сразу.
const webhookUrl = `${process.env.BACKEND_URL}/webhook/greenapi/`; // базовый URL

const instanceSettings = {
partnerUserUiid: member_id, // Привязываем инстанс к пользователю
webhookUrl: webhookUrl, // Будет дополнено ID инстанса самим Green API или мы должны это сделать? Документация не ясна. Предположим, что мы должны передать полный URL.
outgoingWebhook: "yes",
incomingWebhook: "yes",
stateWebhook: "yes"
};

// В документации сказано, что webhookUrl будет использоваться как есть.
// Но мы не знаем idInstance заранее. Это проблема курицы и яйца.
// Решение: мы не будем устанавливать webhookUrl при создании.
// Мы создадим инстанс, получим idInstance, а затем обновим его настройки.
// Это возвращает нас к исходной логике.

// 2. Выполнить запрос к Green API на создание инстанса
const { idInstance, apiTokenInstance } = await greenApiService.createInstance({}); // Создаем без настроек
if (!idInstance || !apiTokenInstance) {
throw new Error('Не удалось создать инстанс в Green API.');
}

// 3. Сохранить данные в БД
const newInstance = await Instance.create({
client_id: client.id,
idInstance,
apiTokenInstance, // Токен будет зашифрован сеттером в модели
name: tempInstanceName,
status: 'notAuthorized'
});

// 4. Установить вебхуки, теперь у нас есть idInstance
const finalWebhookUrl = `${process.env.BACKEND_URL}/webhook/greenapi/${idInstance}`;
await greenApiService.setSettings(idInstance, apiTokenInstance, {
webhookUrl: finalWebhookUrl,
outgoingWebhook: "yes",
incomingWebhook: "yes",
stateWebhook: "yes"
});
console.log(`Вебхук для инстанса ${idInstance} установлен на ${finalWebhookUrl}`);

// 5. Получить QR-код
const qrCodeBase64 = await greenApiService.getQrCode(idInstance, apiTokenInstance);

// 6. Вернуть успешный ответ
res.status(201).json({
id: newInstance.id,
idInstance: newInstance.idInstance,
qr_code_base64: qrCodeBase64,
status: 'notAuthorized'
});

} catch (error) {
console.error('Ошибка при создании инстанса:', error);
res.status(500).json({
error: true,
message: error.message || 'Не удалось создать инстанс.'
});
}
};

module.exports = {
createInstance
};
54 changes: 54 additions & 0 deletions instance-manager/controllers/webhook.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// =================================================================
// Файл: controllers/webhook.controller.js
// Описание: Контроллер для обработки входящих вебхуков от Green API.
// =================================================================
const Instance = require('../../database/models/instance.model');

const handleWebhook = async (req, res, next) => {
const { idInstance } = req.params;
const webhookData = req.body;

console.log(`Получен вебхук для инстанса ${idInstance}:`, JSON.stringify(webhookData, null, 2));

try {
// Находим инстанс в нашей БД, чтобы убедиться, что он легитимен
const instance = await Instance.findOne({ where: { idInstance } });
if (!instance) {
console.warn(`Получен вебхук для неизвестного инстанса: ${idInstance}`);
return res.status(404).send('Instance not found');
}

// Логика обработки вебхука
if (webhookData.typeWebhook === 'stateInstanceChanged') {
const newState = webhookData.stateInstance;
console.log(`Статус инстанса ${idInstance} изменен на: ${newState}`);

// Обновляем статус в БД
instance.status = newState;

// Если инстанс авторизован, сохраняем номер телефона
if (newState === 'authorized' && webhookData.wid) {
// wid обычно имеет формат "номер@c.us"
instance.phone_number = webhookData.wid.split('@')[0];
console.log(`Номер телефона для инстанса ${idInstance} установлен: ${instance.phone_number}`);
}

await instance.save();
} else if (webhookData.typeWebhook === 'incomingMessageReceived') {
console.log(`Новое входящее сообщение для ${idInstance} от ${webhookData.senderData.sender}`);
// Здесь будет логика для отправки сообщения в Открытую Линию Битрикс24
}

// Отвечаем Green API, что вебхук получен успешно
res.status(200).send('OK');

} catch (error) {
console.error(`Ошибка при обработке вебхука для ${idInstance}:`, error);
// Не отправляем ошибку в next(), чтобы не ронять сервер из-за вебхуков
res.status(500).send('Internal Server Error');
}
};

module.exports = {
handleWebhook
};
15 changes: 15 additions & 0 deletions instance-manager/database/database.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// =================================================================
// Файл: database/database.js
// Описание: Настройка подключения к базе данных с помощью Sequelize.
// =================================================================
const { Sequelize } = require('sequelize');

// Используем SQLite для простоты. Файл `database.sqlite` будет создан в корне проекта.
// Для продакшена можно легко заменить на PostgreSQL, MySQL и т.д.
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: './database.sqlite',
logging: false // Отключить логирование SQL-запросов в консоль
});

module.exports = sequelize;
31 changes: 31 additions & 0 deletions instance-manager/database/models/client.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// =================================================================
// Файл: database/models/client.model.js
// Описание: Модель Sequelize для таблицы "Clients".
// =================================================================
const { DataTypes } = require('sequelize');
const sequelize = require('../database');

const Client = sequelize.define('Client', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
portal_url: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
comment: 'URL-адрес портала Битрикс24'
},
member_id: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
comment: 'Уникальный ID портала от Битрикс24'
}
}, {
tableName: 'Clients',
timestamps: true, // Добавляет поля createdAt и updatedAt
});

module.exports = Client;
65 changes: 65 additions & 0 deletions instance-manager/database/models/instance.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// =================================================================
// Файл: database/models/instance.model.js
// Описание: Модель Sequelize для таблицы "Instances".
// =================================================================
const { DataTypes } = require('sequelize');
const sequelize = require('../database');
const Client = require('./client.model');
const { encrypt, decrypt } = require('../../services/encryption.service');

const Instance = sequelize.define('Instance', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
client_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Client,
key: 'id'
},
comment: 'Внешний ключ к таблице Clients'
},
idInstance: {
type: DataTypes.STRING,
allowNull: false
},
apiTokenInstance: {
type: DataTypes.STRING,
allowNull: false,
// Сеттер для шифрования токена перед сохранением в БД
set(value) {
this.setDataValue('apiTokenInstance', encrypt(value));
}
},
name: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Пользовательское название (например, "Отдел продаж")'
},
phone_number: {
type: DataTypes.STRING,
allowNull: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'notAuthorized',
comment: 'Статус: notAuthorized, authorized, sleepMode и т.д.'
}
}, {
tableName: 'Instances',
timestamps: true
});

// Определение связи "один ко многим"
Client.hasMany(Instance, { foreignKey: 'client_id', as: 'instances' });
Instance.belongsTo(Client, { foreignKey: 'client_id', as: 'client' });

// Добавляем "прототипный" метод для получения расшифрованного токена
Instance.prototype.getDecryptedToken = function() {
return decrypt(this.apiTokenInstance);
};

module.exports = Instance;
Loading