feat: новая функция
This commit is contained in:
@@ -10,439 +10,186 @@
|
||||
* GitHub: https://github.com/HB3-ACCELERATOR
|
||||
*/
|
||||
|
||||
const { ChatOllama } = require('@langchain/ollama');
|
||||
const aiCache = require('./ai-cache');
|
||||
const AIQueue = require('./ai-queue');
|
||||
const logger = require('../utils/logger');
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
|
||||
// Константы для AI параметров
|
||||
const AI_CONFIG = {
|
||||
temperature: 0.3,
|
||||
maxTokens: 512,
|
||||
timeout: 120000, // Уменьшаем до 120 секунд, чтобы соответствовать EmailBot
|
||||
numCtx: 2048,
|
||||
numGpu: 1,
|
||||
numThread: 4,
|
||||
repeatPenalty: 1.1,
|
||||
topK: 40,
|
||||
topP: 0.9,
|
||||
// tfsZ не поддерживается в текущем Ollama — удаляем
|
||||
mirostat: 2,
|
||||
mirostatTau: 5,
|
||||
mirostatEta: 0.1,
|
||||
seed: -1,
|
||||
// Ограничим количество генерируемых токенов для CPU, чтобы избежать таймаутов
|
||||
numPredict: 256,
|
||||
stop: []
|
||||
};
|
||||
|
||||
/**
|
||||
* AI Assistant - тонкая обёртка для работы с Ollama и RAG
|
||||
* Основная логика вынесена в отдельные сервисы:
|
||||
* - ragService.js - генерация ответов через RAG
|
||||
* - aiAssistantSettingsService.js - настройки ИИ
|
||||
* - aiAssistantRulesService.js - правила ИИ
|
||||
* - messageDeduplicationService.js - дедупликация сообщений
|
||||
* - ai-queue.js - управление очередью (отдельный сервис)
|
||||
*/
|
||||
class AIAssistant {
|
||||
constructor() {
|
||||
this.baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
||||
this.defaultModel = process.env.OLLAMA_MODEL || 'qwen2.5:7b';
|
||||
this.lastHealthCheck = 0;
|
||||
this.healthCheckInterval = 300000; // 5 минут (увеличено с 30 секунд для уменьшения логов)
|
||||
|
||||
// Создаем экземпляр AIQueue
|
||||
this.aiQueue = new AIQueue();
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
// Запускаем обработку очереди
|
||||
this.startQueueProcessing();
|
||||
this.baseUrl = null;
|
||||
this.defaultModel = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
// Запуск обработки очереди
|
||||
async startQueueProcessing() {
|
||||
if (this.isProcessingQueue) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
logger.info('[AIAssistant] Запущена обработка очереди AIQueue');
|
||||
|
||||
while (this.isProcessingQueue) {
|
||||
try {
|
||||
// Получаем следующий запрос из очереди
|
||||
const requestItem = this.aiQueue.getNextRequest();
|
||||
|
||||
if (!requestItem) {
|
||||
// Если очередь пуста, ждем немного
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`[AIAssistant] Обрабатываем запрос ${requestItem.id} из очереди`);
|
||||
|
||||
// Обновляем статус на "processing"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'processing');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Обрабатываем запрос
|
||||
const result = await this.processQueueRequest(requestItem.request);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Обновляем статус на "completed"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'completed', result, null, responseTime);
|
||||
|
||||
logger.info(`[AIAssistant] Запрос ${requestItem.id} завершен за ${responseTime}ms`);
|
||||
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Обновляем статус на "failed"
|
||||
this.aiQueue.updateRequestStatus(requestItem.id, 'failed', null, error.message, responseTime);
|
||||
|
||||
logger.error(`[AIAssistant] Запрос ${requestItem.id} завершился с ошибкой:`, error.message);
|
||||
logger.error(`[AIAssistant] Детали ошибки:`, error.stack || error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AIAssistant] Ошибка в обработке очереди:', error);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Остановка обработки очереди
|
||||
stopQueueProcessing() {
|
||||
this.isProcessingQueue = false;
|
||||
logger.info('[AIAssistant] Остановлена обработка очереди AIQueue');
|
||||
}
|
||||
|
||||
// Обработка запроса из очереди
|
||||
async processQueueRequest(request) {
|
||||
/**
|
||||
* Инициализация из БД
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
const { message, history, systemPrompt, rules } = request;
|
||||
await ollamaConfig.loadSettingsFromDb();
|
||||
|
||||
// Используем прямой запрос к API, а не getResponse (чтобы избежать цикла)
|
||||
const result = await this.directRequest(
|
||||
[{ role: 'user', content: message }],
|
||||
systemPrompt,
|
||||
{ temperature: 0.3, maxTokens: 150 }
|
||||
);
|
||||
this.baseUrl = ollamaConfig.getBaseUrl();
|
||||
this.defaultModel = ollamaConfig.getDefaultModel();
|
||||
|
||||
return result;
|
||||
if (!this.baseUrl || !this.defaultModel) {
|
||||
throw new Error('Настройки Ollama не найдены в БД');
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info(`[AIAssistant] ✅ Инициализирован из БД: model=${this.defaultModel}`);
|
||||
} catch (error) {
|
||||
logger.error(`[AIAssistant] Ошибка в processQueueRequest:`, error.message);
|
||||
logger.error(`[AIAssistant] Stack trace:`, error.stack);
|
||||
throw error; // Перебрасываем ошибку дальше
|
||||
}
|
||||
}
|
||||
|
||||
// Добавление запроса в очередь
|
||||
async addToQueue(request, priority = 0) {
|
||||
return await this.aiQueue.addRequest(request, priority);
|
||||
}
|
||||
|
||||
// Получение статистики очереди
|
||||
getQueueStats() {
|
||||
return this.aiQueue.getStats();
|
||||
}
|
||||
|
||||
// Получение размера очереди
|
||||
getQueueSize() {
|
||||
return this.aiQueue.getQueueSize();
|
||||
}
|
||||
|
||||
// Проверка здоровья модели
|
||||
async checkModelHealth() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastHealthCheck < this.healthCheckInterval) {
|
||||
return true; // Используем кэшированный результат
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const modelExists = data.models?.some(model => model.name === this.defaultModel);
|
||||
|
||||
this.lastHealthCheck = now;
|
||||
return modelExists;
|
||||
} catch (error) {
|
||||
logger.error('Model health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка старого кэша
|
||||
cleanupCache() {
|
||||
const now = Date.now();
|
||||
const maxAge = 3600000; // 1 час
|
||||
aiCache.cleanup(maxAge);
|
||||
}
|
||||
|
||||
// Создание чата с кастомным системным промптом
|
||||
createChat(customSystemPrompt = '') {
|
||||
let systemPrompt = customSystemPrompt;
|
||||
if (!systemPrompt) {
|
||||
systemPrompt = 'Вы - полезный ассистент. Отвечайте на русском языке кратко и по делу.';
|
||||
}
|
||||
|
||||
return new ChatOllama({
|
||||
baseUrl: this.baseUrl,
|
||||
model: this.defaultModel,
|
||||
system: systemPrompt,
|
||||
...AI_CONFIG,
|
||||
options: AI_CONFIG
|
||||
});
|
||||
}
|
||||
|
||||
// Определение приоритета запроса
|
||||
getRequestPriority(message, history, rules) {
|
||||
let priority = 0;
|
||||
|
||||
// Высокий приоритет для коротких запросов
|
||||
if (message.length < 50) {
|
||||
priority += 10;
|
||||
}
|
||||
|
||||
// Приоритет по типу запроса
|
||||
const urgentKeywords = ['срочно', 'важно', 'помоги'];
|
||||
if (urgentKeywords.some(keyword => message.toLowerCase().includes(keyword))) {
|
||||
priority += 20;
|
||||
}
|
||||
|
||||
// Приоритет для администраторов
|
||||
if (rules && rules.isAdmin) {
|
||||
priority += 15;
|
||||
}
|
||||
|
||||
// Приоритет по времени ожидания (если есть история)
|
||||
if (history && history.length > 0) {
|
||||
const lastMessage = history[history.length - 1];
|
||||
const timeDiff = Date.now() - (lastMessage.timestamp || Date.now());
|
||||
if (timeDiff > 30000) { // Более 30 секунд ожидания
|
||||
priority += 5;
|
||||
}
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
// Основной метод для получения ответа
|
||||
async getResponse(message, history = null, systemPrompt = '', rules = null) {
|
||||
try {
|
||||
// Очищаем старый кэш
|
||||
this.cleanupCache();
|
||||
|
||||
// Проверяем здоровье модели
|
||||
const isHealthy = await this.checkModelHealth();
|
||||
if (!isHealthy) {
|
||||
return 'Извините, модель временно недоступна. Пожалуйста, попробуйте позже.';
|
||||
}
|
||||
|
||||
// Проверяем кэш
|
||||
const cacheKey = aiCache.generateKey([{ role: 'user', content: message }], {
|
||||
temperature: 0.3,
|
||||
maxTokens: 150
|
||||
});
|
||||
const cachedResponse = aiCache.get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Определяем приоритет запроса
|
||||
const priority = this.getRequestPriority(message, history, rules);
|
||||
|
||||
// Добавляем запрос в очередь
|
||||
const requestId = await this.addToQueue({
|
||||
message,
|
||||
history,
|
||||
systemPrompt,
|
||||
rules
|
||||
}, priority);
|
||||
|
||||
// Ждем результат из очереди
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Request timeout - очередь перегружена'));
|
||||
}, 180000); // 180 секунд таймаут для очереди
|
||||
|
||||
const onCompleted = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
this.aiQueue.off('requestCompleted', onCompleted);
|
||||
this.aiQueue.off('requestFailed', onFailed);
|
||||
try {
|
||||
aiCache.set(cacheKey, item.result);
|
||||
} catch {}
|
||||
resolve(item.result);
|
||||
}
|
||||
};
|
||||
|
||||
const onFailed = (item) => {
|
||||
if (item.id === requestId) {
|
||||
clearTimeout(timeout);
|
||||
this.aiQueue.off('requestCompleted', onCompleted);
|
||||
this.aiQueue.off('requestFailed', onFailed);
|
||||
reject(new Error(item.error));
|
||||
}
|
||||
};
|
||||
|
||||
this.aiQueue.on('requestCompleted', onCompleted);
|
||||
this.aiQueue.on('requestFailed', onFailed);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in getResponse:', error);
|
||||
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
|
||||
}
|
||||
}
|
||||
|
||||
// Алиас для getResponse (для совместимости)
|
||||
async processMessage(message, history = null, systemPrompt = '', rules = null) {
|
||||
return this.getResponse(message, history, systemPrompt, rules);
|
||||
}
|
||||
|
||||
// Прямой запрос к API (для очереди)
|
||||
async directRequest(messages, systemPrompt = '', optionsOverride = {}) {
|
||||
try {
|
||||
const model = this.defaultModel;
|
||||
|
||||
logger.info(`[AIAssistant] directRequest: модель=${model}, сообщений=${messages?.length || 0}, systemPrompt="${systemPrompt?.substring(0, 50)}..."`);
|
||||
|
||||
// Создаем AbortController для таймаута
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), AI_CONFIG.timeout);
|
||||
|
||||
// Маппинг camelCase → snake_case для опций Ollama
|
||||
const mapOptionsToOllama = (opts) => ({
|
||||
temperature: opts.temperature,
|
||||
// Используем только num_predict; не мапим maxTokens, чтобы не завышать лимит генерации
|
||||
num_predict: typeof opts.numPredict === 'number' && opts.numPredict > 0 ? opts.numPredict : undefined,
|
||||
num_ctx: opts.numCtx,
|
||||
num_gpu: opts.numGpu,
|
||||
num_thread: opts.numThread,
|
||||
repeat_penalty: opts.repeatPenalty,
|
||||
top_k: opts.topK,
|
||||
top_p: opts.topP,
|
||||
tfs_z: opts.tfsZ,
|
||||
mirostat: opts.mirostat,
|
||||
mirostat_tau: opts.mirostatTau,
|
||||
mirostat_eta: opts.mirostatEta,
|
||||
seed: opts.seed,
|
||||
stop: Array.isArray(opts.stop) ? opts.stop : []
|
||||
});
|
||||
|
||||
const mergedConfig = { ...AI_CONFIG, ...optionsOverride };
|
||||
const ollamaOptions = mapOptionsToOllama(mergedConfig);
|
||||
|
||||
// Вставляем системный промпт в начало, если задан
|
||||
const finalMessages = Array.isArray(messages) ? [...messages] : [];
|
||||
// Нормализация: только 'user' | 'assistant' | 'system'
|
||||
for (const m of finalMessages) {
|
||||
if (m && m.role) {
|
||||
if (m.role !== 'assistant' && m.role !== 'system') m.role = 'user';
|
||||
}
|
||||
}
|
||||
if (systemPrompt && !finalMessages.find(m => m.role === 'system')) {
|
||||
finalMessages.unshift({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
logger.info(`[AIAssistant] Вызываю Ollama API: ${this.baseUrl}/api/chat`);
|
||||
response = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: finalMessages,
|
||||
stream: false,
|
||||
options: ollamaOptions
|
||||
})
|
||||
});
|
||||
logger.info(`[AIAssistant] Ollama API ответил: status=${response.status}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Ollama /api/chat возвращает ответ в data.message.content
|
||||
if (data.message && typeof data.message.content === 'string') {
|
||||
const content = data.message.content;
|
||||
try {
|
||||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||||
aiCache.set(cacheKey, content);
|
||||
} catch {}
|
||||
return content;
|
||||
}
|
||||
// OpenAI-совместимый /v1/chat/completions
|
||||
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||||
const content = data.choices[0].message.content;
|
||||
try {
|
||||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||||
aiCache.set(cacheKey, content);
|
||||
} catch {}
|
||||
return content;
|
||||
}
|
||||
|
||||
const content = data.response || '';
|
||||
try {
|
||||
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
|
||||
aiCache.set(cacheKey, content);
|
||||
} catch {}
|
||||
return content;
|
||||
} catch (error) {
|
||||
logger.error('Error in directRequest:', error);
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout - модель не ответила в течение 120 секунд');
|
||||
}
|
||||
logger.error('[AIAssistant] ❌ КРИТИЧЕСКАЯ ОШИБКА загрузки настроек из БД:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение списка доступных моделей
|
||||
async getAvailableModels() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||||
const data = await response.json();
|
||||
return data.models || [];
|
||||
} catch (error) {
|
||||
logger.error('Error getting available models:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Генерация ответа для всех каналов (web, telegram, email)
|
||||
* Используется ботами (telegramBot, emailBot)
|
||||
*/
|
||||
async generateResponse(options) {
|
||||
const {
|
||||
channel,
|
||||
messageId,
|
||||
userId,
|
||||
userQuestion,
|
||||
conversationHistory = [],
|
||||
conversationId,
|
||||
ragTableId = null,
|
||||
metadata = {}
|
||||
} = options;
|
||||
|
||||
// Проверка здоровья AI сервиса
|
||||
async checkHealth() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API returned ${response.status}`);
|
||||
logger.info(`[AIAssistant] Генерация ответа для канала ${channel}, пользователь ${userId}`);
|
||||
|
||||
const messageDeduplicationService = require('./messageDeduplicationService');
|
||||
const aiAssistantSettingsService = require('./aiAssistantSettingsService');
|
||||
const aiAssistantRulesService = require('./aiAssistantRulesService');
|
||||
const { ragAnswer } = require('./ragService');
|
||||
|
||||
// 1. Проверяем дедупликацию
|
||||
const cleanMessageId = messageDeduplicationService.cleanMessageId(messageId, channel);
|
||||
const isAlreadyProcessed = await messageDeduplicationService.isMessageAlreadyProcessed(
|
||||
channel,
|
||||
cleanMessageId,
|
||||
userId,
|
||||
'user'
|
||||
);
|
||||
|
||||
if (isAlreadyProcessed) {
|
||||
logger.info(`[AIAssistant] Сообщение ${cleanMessageId} уже обработано - пропускаем`);
|
||||
return { success: false, reason: 'duplicate' };
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 2. Получаем настройки AI ассистента
|
||||
const aiSettings = await aiAssistantSettingsService.getSettings();
|
||||
let rules = null;
|
||||
if (aiSettings && aiSettings.rules_id) {
|
||||
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
|
||||
}
|
||||
|
||||
// 3. Генерируем AI ответ через RAG
|
||||
const aiResponse = await ragAnswer({
|
||||
userQuestion,
|
||||
conversationHistory,
|
||||
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
|
||||
rules: rules ? rules.rules : null,
|
||||
ragTableId
|
||||
});
|
||||
|
||||
if (!aiResponse) {
|
||||
logger.warn(`[AIAssistant] Пустой ответ от AI для пользователя ${userId}`);
|
||||
return { success: false, reason: 'empty_response' };
|
||||
}
|
||||
|
||||
// 4. Сохраняем ответ с дедупликацией
|
||||
const aiResponseId = `ai_response_${cleanMessageId}_${Date.now()}`;
|
||||
const saveResult = await messageDeduplicationService.saveMessageWithDeduplication(
|
||||
{
|
||||
user_id: userId,
|
||||
conversation_id: conversationId,
|
||||
sender_type: 'assistant',
|
||||
content: aiResponse,
|
||||
channel: channel,
|
||||
role: 'assistant',
|
||||
direction: 'out',
|
||||
created_at: new Date(),
|
||||
...metadata
|
||||
},
|
||||
channel,
|
||||
aiResponseId,
|
||||
userId,
|
||||
'assistant',
|
||||
'messages'
|
||||
);
|
||||
|
||||
if (!saveResult.success) {
|
||||
logger.error(`[AIAssistant] Ошибка сохранения AI ответа:`, saveResult.error);
|
||||
return { success: false, reason: 'save_error' };
|
||||
}
|
||||
|
||||
logger.info(`[AIAssistant] AI ответ успешно сгенерирован и сохранен для пользователя ${userId}`);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
models: data.models?.length || 0,
|
||||
baseUrl: this.baseUrl
|
||||
success: true,
|
||||
response: aiResponse,
|
||||
messageId: aiResponseId,
|
||||
conversationId: conversationId
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AI health check failed:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
baseUrl: this.baseUrl
|
||||
};
|
||||
logger.error(`[AIAssistant] Ошибка генерации ответа:`, error);
|
||||
return { success: false, reason: 'error', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем методы из vectorStore.js
|
||||
async initVectorStore() {
|
||||
// ... код инициализации ...
|
||||
/**
|
||||
* Простая генерация ответа (для гостевых сообщений)
|
||||
* Используется в guestMessageService
|
||||
*/
|
||||
async getResponse(message, history = null, systemPrompt = '', rules = null) {
|
||||
try {
|
||||
const { ragAnswer } = require('./ragService');
|
||||
|
||||
const result = await ragAnswer({
|
||||
userQuestion: message,
|
||||
conversationHistory: history || [],
|
||||
systemPrompt: systemPrompt || '',
|
||||
rules: rules || null,
|
||||
ragTableId: null
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[AIAssistant] Ошибка в getResponse:', error);
|
||||
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
|
||||
}
|
||||
}
|
||||
|
||||
async findSimilarDocuments(query, k = 3) {
|
||||
// ... код поиска документов ...
|
||||
/**
|
||||
* Проверка здоровья AI сервиса
|
||||
* Использует централизованный метод из ollamaConfig
|
||||
*/
|
||||
async checkHealth() {
|
||||
if (!this.isInitialized) {
|
||||
return { status: 'error', error: 'AI Assistant не инициализирован' };
|
||||
}
|
||||
|
||||
// Используем метод проверки из ollamaConfig
|
||||
return await ollamaConfig.checkHealth();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AIAssistant();
|
||||
const aiAssistantInstance = new AIAssistant();
|
||||
const initPromise = aiAssistantInstance.initialize();
|
||||
|
||||
module.exports = aiAssistantInstance;
|
||||
module.exports.initPromise = initPromise;
|
||||
|
||||
Reference in New Issue
Block a user