From f3e1c9b34c6bb9816ad7489ee4efb4e3d689c6e0 Mon Sep 17 00:00:00 2001 From: Anton Korotkov Date: Sun, 11 Aug 2024 21:56:39 +0200 Subject: [PATCH 1/5] wip monitors --- app/Dockerfile | 2 +- app/bot/conversations/addMonitor.js | 65 +++++++++++++++++-- app/bot/conversations/search.js | 15 +++-- .../keyboards/AbstractPersonalizedCache.js | 8 +-- app/bot/keyboards/MarketDetailsKeyboard.js | 55 +++++++++++++--- app/bot/locales/en.ftl | 31 +++++++-- app/bot/locales/ru.ftl | 31 +++++++-- app/bot/locales/uk.ftl | 31 +++++++-- app/db/schemas/Monitor.js | 17 ++++- app/market/Market.js | 7 ++ app/market/MarketsService.js | 6 +- app/utils/round.js | 2 +- docker-compose.yml | 7 +- 13 files changed, 231 insertions(+), 46 deletions(-) diff --git a/app/Dockerfile b/app/Dockerfile index e2ce0b8..0ab3f38 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,5 +1,5 @@ # Use the official Node.js image as the base image -FROM node:18 +FROM node:20-alpine # Create and change to the app directory WORKDIR /usr/src/app diff --git a/app/bot/conversations/addMonitor.js b/app/bot/conversations/addMonitor.js index d818b0e..f97ab20 100644 --- a/app/bot/conversations/addMonitor.js +++ b/app/bot/conversations/addMonitor.js @@ -1,9 +1,66 @@ -module.exports = options => bot => { +const { InlineKeyboard } = require("grammy"); +const Monitor = require("../../db/schemas/Monitor"); +const TYPE_FIXED = 'fixed'; +const TYPE_PERCENTAGE = 'percentage'; +const CONFIRM_CREATE = 'create'; +const CONFIRM_CANCEL = 'cancel'; + +module.exports = ({ logger }) => bot => { return async function addMonitorConversation(conversation, ctx) { - await ctx.reply('Add monitor conversation started'); - await ctx.reply(ctx.state.coin); + const typeKeyboard = new InlineKeyboard(); + typeKeyboard.text(ctx.t('monitor_type_fixed'), TYPE_FIXED).text(ctx.t('monitor_type_percentage'), TYPE_PERCENTAGE); + + await ctx.reply(ctx.t('monitor_type_prompt'), { + reply_markup: typeKeyboard + }); + + const monitorTypeCtx = await conversation.waitForCallbackQuery([TYPE_FIXED, TYPE_PERCENTAGE], { + otherwise: ctx => ctx.reply(ctx.t('use_buttons_alert'), { reply_markup: typeKeyboard }), + }); + + await monitorTypeCtx.editMessageText(ctx.t('monitor_threshold_prompt', { type: monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : '$' }), { + reply_markup: undefined + }); + + const value = await conversation.form.number( + ctx => ctx.reply(ctx.t('monitor_number_validation'), { parse_mode: 'HTML' }) + ); + + if (value <= 0 || value > 1_000_000) + return await ctx.reply('Cannot create monitor with this value'); + + const confirmationKeyboard = new InlineKeyboard(); + confirmationKeyboard.text('Create', CONFIRM_CREATE).text('Cancel', CONFIRM_CANCEL); + await ctx.reply(`You are about to create a ${monitorTypeCtx.match} price monitor for ${ctx.state.coin} with the threshold of ${value}${monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : ''}`, { + parse_mode: 'HTML', + reply_markup: confirmationKeyboard + }); + + const confirmationCtx = await conversation.waitForCallbackQuery([CONFIRM_CREATE, CONFIRM_CANCEL], { + otherwise: ctx => ctx.reply(ctx.t('use_buttons_alert'), { reply_markup: confirmationKeyboard }), + }); + + if (confirmationCtx.match === CONFIRM_CANCEL) + return await ctx.reply('😒'); + + try { + await Monitor.createOrUpdate({ + telegramId: ctx.chat.id, + coinId: ctx.state.id, + coin: ctx.state.coin, + lastPrice: ctx.state.price, + threshold: { + type: monitorTypeCtx.match, + value: value + } + }); + + await ctx.reply(ctx.t('monitor_created')); + } catch (err) { + logger.debug('addMonitor')(err); - const { msg: { text } } = await conversation.waitFor("message:text"); + await ctx.reply('Sorry, there was an error on our side. Please try again later or contact administrator.') + } }; }; \ No newline at end of file diff --git a/app/bot/conversations/search.js b/app/bot/conversations/search.js index 9018802..5331f2d 100644 --- a/app/bot/conversations/search.js +++ b/app/bot/conversations/search.js @@ -4,17 +4,18 @@ module.exports = options => bot => { const marketsService = options.marketsService; /** - * @param {string} coin + * @param {string} id * @param {import('grammy').CallbackQueryContext} ctx */ - const onItemClick = async (coin, ctx) => { - const market = marketsService.getMarketByCoin(coin); + const onItemClick = async (id, ctx) => { + const market = marketsService.getMarketById(id); if (!market) return await ctx.reply(ctx.t('coin_not_found')); const userId = ctx.from.id; - const message = marketDetails.setMarket(userId, market).getMessage(userId); + const details = await marketDetails.setMarket(userId, market); + const message = details.getMessage(userId); const reply_markup = await marketDetails.getMarkup(userId, ctx); if (!message || !reply_markup) @@ -26,8 +27,8 @@ module.exports = options => bot => { }); } - const onAddMonitor = async (coin, ctx) => { - ctx.state = { coin }; + const onAddMonitor = async (id, coin, price, ctx) => { + ctx.state = { id, coin, price }; await ctx.conversation.enter('addMonitorConversation'); }; @@ -39,7 +40,7 @@ module.exports = options => bot => { pagination.init(bot, { onItemClick, renderItem: i => `${i.getName()} (${i.getCoin()})`, - resolveItemId: i => i.getCoin() + resolveItemId: i => i.getId() }); return async function searchConversation(conversation, ctx) { diff --git a/app/bot/keyboards/AbstractPersonalizedCache.js b/app/bot/keyboards/AbstractPersonalizedCache.js index 6cdd9b5..be74436 100644 --- a/app/bot/keyboards/AbstractPersonalizedCache.js +++ b/app/bot/keyboards/AbstractPersonalizedCache.js @@ -47,13 +47,13 @@ class AbstractPersonalizedCache { if (!this.#cache[userId]) return this; - if (entityName && this.#cache[userId][entityName]) { - this.#cache[userId][entityName] = undefined; - + if (!entityName) { + this.#cache[userId] = undefined; return this; } - this.#cache[userId] = undefined; + if (this.#cache[userId][entityName]) + this.#cache[userId][entityName] = undefined; return this; } diff --git a/app/bot/keyboards/MarketDetailsKeyboard.js b/app/bot/keyboards/MarketDetailsKeyboard.js index b60d9ae..8823239 100644 --- a/app/bot/keyboards/MarketDetailsKeyboard.js +++ b/app/bot/keyboards/MarketDetailsKeyboard.js @@ -11,9 +11,15 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { */ init(bot, { onAddMonitor, onShowMonitor }) { bot.on("callback_query:data", async (ctx, next) => { - if (ctx.callbackQuery.data.includes('addMonitor:')) { - const [, coin] = ctx.callbackQuery.data.split(':'); - await onAddMonitor(coin, ctx); + if (ctx.callbackQuery.data.includes('addMonitor')) { + if (!this.#canAddMonitor(ctx.chat.id)) + return await ctx.reply('You cannot add more than 5 monitors.'); + + const market = this.#getMarket(ctx.chat.id); + if (!market) + return await ctx.reply('Oops... Looks like you were thinking for too long. Try again from search.'); + + await onAddMonitor(market.getId(), market.getCoin(), market.getPrice(true), ctx); } if (ctx.callbackQuery.data.includes('showMonitor:')) { @@ -29,10 +35,26 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { * @param {number} userId * @param {import('../../market/Market')} market */ - setMarket(userId, market) { + async setMarket(userId, market) { + const userMonitors = await Monitor.find({ telegramId: userId }); + this.setUserCacheEntity(userId, 'can_add_monitor', userMonitors.length < 5); + + const existingMonitor = userMonitors.find(m => m.coinId === market.getId()); + if (existingMonitor) + this.setUserCacheEntity(userId, 'market_monitor', existingMonitor); + else + this.flushUserCache(userId, 'market_monitor'); + return this.setUserCacheEntity(userId, 'market_details', market); } + /** + * @param {string} userId + */ + getMarketMonitor(userId) { + return this.getUserCache(userId, 'market_monitor'); + } + /** * @param {number} userId * @returns {import('../../market/Market') | undefined} @@ -41,6 +63,10 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { return this.getUserCache(userId, 'market_details'); } + #canAddMonitor(userId) { + return this.getUserCache(userId, 'can_add_monitor'); + } + /** * @param {number} userId */ @@ -50,15 +76,26 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { return undefined; const updated = moment(market.getLastUpdated()); + const monitor = this.getMarketMonitor(userId); - return ['market_message', { + const messageParams = { name: market.getName(), coin: market.getCoin(), price: Intl.NumberFormat().format(market.getPrice(true)), fiat: market.getFiat(), volume: truncate(market.getVolume(true)), last_update: updated.from(moment()) - }]; + }; + + if (monitor) { + const monitorMessageParams = { + monitor_type: monitor.threshold.type === 'percentage' ? '%' : '$', + monitor_value: monitor.threshold.value + }; + return ['market_message_monitor', { ...messageParams, ...monitorMessageParams }]; + } + + return ['market_message', messageParams]; } /** @@ -70,14 +107,14 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { if (!market) return undefined; - const existingMonitor = await Monitor.findOne({ coin: market.getCoin(), telegramId: userId }); + const existingMonitor = this.getMarketMonitor(userId); const keyboard = new InlineKeyboard(); keyboard.row(); if (!existingMonitor) - keyboard.text(ctx.t('add_monitor'), `addMonitor:${market.getCoin()}`); + keyboard.text(ctx.t('add_monitor'), 'addMonitor'); else - keyboard.text(ctx.t('show_monitor'), `showMonitor:${existingMonitor._id}`); + keyboard.text(ctx.t('delete_monitor'), `deleteMonitor:${existingMonitor._id}`); return keyboard; } diff --git a/app/bot/locales/en.ftl b/app/bot/locales/en.ftl index 80942ca..2efcf05 100644 --- a/app/bot/locales/en.ftl +++ b/app/bot/locales/en.ftl @@ -12,10 +12,33 @@ found = Found { $number } coin(s). Select one for details and further actions: market_message = { $name } ({ $coin }) - 💵 Price: { $price } { $fiat } - 📈 24H Volume: { $volume } { $fiat } - 📅 Last Update: { $last_update } + Price: { $price } { $fiat } + 24H Volume: { $volume } { $fiat } + Last Update: { $last_update } + +market_message_monitor = + { $name } ({ $coin }) + + Price: { $price } { $fiat } + 24H Volume: { $volume } { $fiat } + Last Update: { $last_update } + + Monitor: ±{ $monitor_value }{ $monitor_type } add_monitor = Add Monitor -show_monitor = Show Monitor \ No newline at end of file +delete_monitor = Delete Monitor + +use_buttons_alert = Use the buttons! + +monitor_type_fixed = Fixed + +monitor_type_percentage = Percentage + +monitor_type_prompt = Select the type of price change to monitor: + +monitor_threshold_prompt = Enter the price change threshold ({ $type }): + +monitor_number_validation = Only numbers, e.g. 12 or 1.4. Max: 1,000,000 + +monitor_created = Monitor successfully created. \ No newline at end of file diff --git a/app/bot/locales/ru.ftl b/app/bot/locales/ru.ftl index fc223d9..7de0d98 100644 --- a/app/bot/locales/ru.ftl +++ b/app/bot/locales/ru.ftl @@ -12,10 +12,33 @@ found = Найдено криптовалют: { $number }. Выберите о market_message = { $name } ({ $coin }) - 💵 Цена: { $price } { $fiat } - 📈 24H Объем: { $volume } { $fiat } - 📅 Обновлено: { $last_update } + Цена: { $price } { $fiat } + 24H Объем: { $volume } { $fiat } + Обновлено: { $last_update } + +market_message_monitor = + { $name } ({ $coin }) + + Цена: { $price } { $fiat } + 24H Объем: { $volume } { $fiat } + Обновлено: { $last_update } + + Мониторинг: ±{ $monitor_value }{ $monitor_type } add_monitor = Добавить Монитор -show_monitor = Показать Монитор \ No newline at end of file +delete_monitor = Удалить Монитор + +use_buttons_alert = Используйте кнопки! + +monitor_type_fixed = Фиксированный + +monitor_type_percentage = Проценты + +monitor_type_prompt = Выберите тип изменения цены для мониторинга: + +monitor_threshold_prompt = Укажите порог изменения цены ({ $type }): + +monitor_number_validation = Только числа, например 12 или 1.4. Максимум: 1,000,000 + +monitor_created = Монитор успешно создан. \ No newline at end of file diff --git a/app/bot/locales/uk.ftl b/app/bot/locales/uk.ftl index 56bcc79..8f3beab 100644 --- a/app/bot/locales/uk.ftl +++ b/app/bot/locales/uk.ftl @@ -12,10 +12,33 @@ found = Знайдено криптовалют: { $number }. Виберіть market_message = { $name } ({ $coin }) - 💵 Ціна: { $price } { $fiat } - 📈 24H Обʼєм: { $volume } { $fiat } - 📅 Оновлено: { $last_update } + Ціна: { $price } { $fiat } + 24H Обʼєм: { $volume } { $fiat } + Оновлено: { $last_update } + +market_message_monitor = + { $name } ({ $coin }) + + Ціна: { $price } { $fiat } + 24H Обʼєм: { $volume } { $fiat } + Оновлено: { $last_update } + + Моніторінг: ±{ $monitor_value }{ $monitor_type } add_monitor = Додати Монітор -show_monitor = Показати Монітор \ No newline at end of file +delete_monitor = Видалити Монітор + +use_buttons_alert = Використовуйте кнопки! + +monitor_type_fixed = Фіксований + +monitor_type_percentage = Відсотки + +monitor_type_prompt = Оберіть тип зміни ціни для моніторінгу: + +monitor_threshold_prompt = Вкажіть поріг зміни ціни ({ $type }): + +monitor_number_validation = Тільки числа, наприклад 12 або 1.4. Максимум: 1,000,000 + +monitor_created = Монітор успішно створений. \ No newline at end of file diff --git a/app/db/schemas/Monitor.js b/app/db/schemas/Monitor.js index 7af46bc..9923b49 100644 --- a/app/db/schemas/Monitor.js +++ b/app/db/schemas/Monitor.js @@ -7,6 +7,10 @@ const MonitorSchema = new Schema({ immutable: true, required: true }, + coinId: { + type: String, + required: true + }, coin: { type: String, required: true @@ -22,11 +26,22 @@ const MonitorSchema = new Schema({ }, type: { type: String, - enum: ['percent', 'fixed'] + enum: ['percentage', 'fixed'], + required: true } } }); +MonitorSchema.statics.createOrUpdate = function ({ + telegramId, coinId, ...data +}) { + return this.findOneAndUpdate( + { telegramId, coinId }, + { $set: data }, + { new: true, upsert: true } + ); +}; + const Monitor = model('Monitor', MonitorSchema); module.exports = Monitor; \ No newline at end of file diff --git a/app/market/Market.js b/app/market/Market.js index 8ff06dc..985f39e 100644 --- a/app/market/Market.js +++ b/app/market/Market.js @@ -1,6 +1,8 @@ const round = require("../utils/round"); module.exports = class Market { + #id; + /** @type {string} */ #coin; @@ -25,6 +27,7 @@ module.exports = class Market { constructor(marketData) { const [coin, fiat] = marketData.Label.split('/'); + this.#id = btoa(`${coin}-${marketData.Name}`); this.#coin = coin; this.#fiat = fiat; this.#name = marketData.Name; @@ -33,6 +36,10 @@ module.exports = class Market { this.#lastUpdated = marketData.Timestamp * 1000; } + getId() { + return this.#id; + } + getCoin() { return this.#coin; } diff --git a/app/market/MarketsService.js b/app/market/MarketsService.js index ed417de..a0dce21 100644 --- a/app/market/MarketsService.js +++ b/app/market/MarketsService.js @@ -2,7 +2,7 @@ const round = require("../utils/round"); const Market = require("./Market"); const apiUrl = 'https://www.worldcoinindex.com/apiservice/v2getmarkets'; -const fetchInterval = 60_000; +const fetchInterval = 300_000; class MarketsService { #log; @@ -79,8 +79,8 @@ class MarketsService { ); } - getMarketByCoin(coin) { - return this.#markets.find(m => m.getCoin() === coin); + getMarketById(id) { + return this.#markets.find(m => m.getId() === id); } } diff --git a/app/utils/round.js b/app/utils/round.js index 5b849b6..54f35c7 100644 --- a/app/utils/round.js +++ b/app/utils/round.js @@ -1,5 +1,5 @@ const round = num => { - return Math.ceil(num * 1000000) / 1000000; + return Math.ceil(num * 10000) / 10000; } module.exports = round; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c3b4b22..e5f7583 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,10 @@ -version: '3' services: rabbitmq: container_name: RabbitMQ image: rabbitmq:management - ports: - - "5672:5672" - - "15672:15672" + # ports: + # - "5672:5672" + # - "15672:15672" networks: - app-network From 7ed6e164dcf4325f3f9210ff5f129885394bfce9 Mon Sep 17 00:00:00 2001 From: Anton Korotkov Date: Sun, 18 Aug 2024 21:06:11 +0200 Subject: [PATCH 2/5] managing monitors + notifications worker wip --- app/Dockerfile | 6 - app/amqp/index.js | 38 ++ app/app.js | 14 +- app/bot/TelegramBot.js | 3 +- app/bot/commands/search.js | 5 +- app/bot/conversations/addMonitor.js | 8 +- app/bot/conversations/deleteMonitor.js | 36 ++ app/bot/conversations/index.js | 4 +- app/bot/conversations/search.js | 15 +- app/bot/keyboards/MarketDetailsKeyboard.js | 10 +- app/bot/locales/en.ftl | 8 +- app/bot/locales/ru.ftl | 8 +- app/bot/locales/uk.ftl | 8 +- app/config.js | 6 +- app/container.js | 5 +- app/market/MarketChangesService.js | 33 ++ app/market/MarketsService.js | 8 +- app/package-lock.json | 86 ++++- app/package.json | 3 +- docker-compose.yml | 6 +- worker/Dockerfile | 13 + worker/db/database.js | 14 + worker/db/schemas/Monitor.js | 47 +++ worker/db/schemas/User.js | 47 +++ worker/index.js | 50 +++ worker/package-lock.json | 408 +++++++++++++++++++++ worker/package.json | 18 + 27 files changed, 870 insertions(+), 37 deletions(-) create mode 100644 app/amqp/index.js create mode 100644 app/bot/conversations/deleteMonitor.js create mode 100644 app/market/MarketChangesService.js create mode 100644 worker/Dockerfile create mode 100644 worker/db/database.js create mode 100644 worker/db/schemas/Monitor.js create mode 100644 worker/db/schemas/User.js create mode 100644 worker/index.js create mode 100644 worker/package-lock.json create mode 100644 worker/package.json diff --git a/app/Dockerfile b/app/Dockerfile index 0ab3f38..01385a1 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,19 +1,13 @@ -# Use the official Node.js image as the base image FROM node:20-alpine -# Create and change to the app directory WORKDIR /usr/src/app -# Copy application dependency manifests to the container image. COPY package*.json ./ -# Install dependencies RUN npm install -# Copy the rest of the application code to the container image. COPY . . ENV DEBUG=* -# Run the application CMD ["node", "index.js"] \ No newline at end of file diff --git a/app/amqp/index.js b/app/amqp/index.js new file mode 100644 index 0000000..5115476 --- /dev/null +++ b/app/amqp/index.js @@ -0,0 +1,38 @@ +const amqp = require('amqplib'); + +class AMQP { + #connection; + + /** @type {amqp.ConfirmChannel | undefined} */ + #channel; + + /** + * @param {string} connectionString + */ + constructor(connectionString) { + this.#connection = amqp.connect(connectionString); + } + + /** + * @param {string} queueName + * @returns {Promise} + */ + async getChannel(queueName) { + if (!this.#channel) { + this.#channel = await (await this.#connection).createConfirmChannel(); + + await this.#channel.assertQueue(queueName, { + durable: true + }); + } + + return this.#channel; + } + + async closeChannel() { + await this.#channel.close(); + this.#channel = undefined; + } +} + +module.exports = AMQP; \ No newline at end of file diff --git a/app/app.js b/app/app.js index f6a2129..6366344 100644 --- a/app/app.js +++ b/app/app.js @@ -2,7 +2,12 @@ class App { #db; #mongoose; #log; + + /** @type {import('./market/MarketsService')} */ #marketsService; + + /** @type {import('./market/MarketChangesService')} */ + #marketChangesService; #telegramBot; constructor(options) { @@ -10,6 +15,7 @@ class App { this.#db = options.db; this.#log = options.logger.debug(this.constructor.name); this.#marketsService = options.marketsService; + this.#marketChangesService = options.marketChangesService; this.#telegramBot = options.telegramBot; this.#log('Initializing...'); @@ -19,8 +25,12 @@ class App { this.#mongoose.connection.once('open', async () => { this.#log('Connected to database!'); - await this.#marketsService.watch(changed => { - this.#log(changed.length); + await this.#marketsService.watch(async changedMarkets => { + for (const market of changedMarkets) { + await this.#marketChangesService.push(market); + } + + await this.#marketChangesService.closeChannel(); }); this.#telegramBot.start(); diff --git a/app/bot/TelegramBot.js b/app/bot/TelegramBot.js index e91491e..8606a4d 100644 --- a/app/bot/TelegramBot.js +++ b/app/bot/TelegramBot.js @@ -24,9 +24,10 @@ class TelegramBot { this.#bot.catch(args => this.errorHandler(args)); this.#bot.use(options.logger.middleware(this.constructor.name)); this.#bot.use(i18n); - this.#bot.use(session({ initial: () => ({}) })); + this.#bot.use(session({ initial: () => ({}), type: 'single' })); this.#bot.use(conversations()); this.#bot.use(createConversation(options.conversationAddMonitor(this.#bot))); + this.#bot.use(createConversation(options.conversationDeleteMonitor(this.#bot))); this.#bot.use(createConversation(options.conversationSearch(this.#bot))); this.#bot.command('start', start); diff --git a/app/bot/commands/search.js b/app/bot/commands/search.js index bb60258..d4fbf49 100644 --- a/app/bot/commands/search.js +++ b/app/bot/commands/search.js @@ -1,5 +1,8 @@ const search = async ctx => { - await ctx.conversation.enter('searchConversation'); + const stats = await ctx.conversation.active(); + + if (!Object.keys(stats).length) + await ctx.conversation.enter('searchConversation'); }; module.exports = search; \ No newline at end of file diff --git a/app/bot/conversations/addMonitor.js b/app/bot/conversations/addMonitor.js index f97ab20..b8b2cc4 100644 --- a/app/bot/conversations/addMonitor.js +++ b/app/bot/conversations/addMonitor.js @@ -6,7 +6,7 @@ const TYPE_PERCENTAGE = 'percentage'; const CONFIRM_CREATE = 'create'; const CONFIRM_CANCEL = 'cancel'; -module.exports = ({ logger }) => bot => { +module.exports = ({ logger }) => _ => { return async function addMonitorConversation(conversation, ctx) { const typeKeyboard = new InlineKeyboard(); typeKeyboard.text(ctx.t('monitor_type_fixed'), TYPE_FIXED).text(ctx.t('monitor_type_percentage'), TYPE_PERCENTAGE); @@ -28,11 +28,11 @@ module.exports = ({ logger }) => bot => { ); if (value <= 0 || value > 1_000_000) - return await ctx.reply('Cannot create monitor with this value'); + return await ctx.reply(ctx.t('monitor_bad_threshold')); const confirmationKeyboard = new InlineKeyboard(); confirmationKeyboard.text('Create', CONFIRM_CREATE).text('Cancel', CONFIRM_CANCEL); - await ctx.reply(`You are about to create a ${monitorTypeCtx.match} price monitor for ${ctx.state.coin} with the threshold of ${value}${monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : ''}`, { + await ctx.reply(`You are about to create a ${monitorTypeCtx.match} price monitor for ${ctx.state.coin} with the threshold of ${value}${monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : '$'}`, { parse_mode: 'HTML', reply_markup: confirmationKeyboard }); @@ -60,7 +60,7 @@ module.exports = ({ logger }) => bot => { } catch (err) { logger.debug('addMonitor')(err); - await ctx.reply('Sorry, there was an error on our side. Please try again later or contact administrator.') + await ctx.reply('Sorry, there was an error on our side. Please try again later or contact administrator.'); } }; }; \ No newline at end of file diff --git a/app/bot/conversations/deleteMonitor.js b/app/bot/conversations/deleteMonitor.js new file mode 100644 index 0000000..28d18c6 --- /dev/null +++ b/app/bot/conversations/deleteMonitor.js @@ -0,0 +1,36 @@ +const { InlineKeyboard } = require("grammy"); +const Monitor = require("../../db/schemas/Monitor"); + +const DELETE_CREATE = 'delete'; +const CONFIRM_CANCEL = 'cancel'; + +module.exports = ({ logger }) => _ => { + return async function deleteMonitorConversation(conversation, ctx) { + try { + const confirmationKeyboard = new InlineKeyboard(); + const { id } = ctx.state; + + if (!id) + return await ctx.reply('Something went wrong. No monitor selected for deletion.'); + + confirmationKeyboard.text('Delete', DELETE_CREATE).text('Cancel', CONFIRM_CANCEL); + await ctx.reply(ctx.t('are_you_sure'), { + reply_markup: confirmationKeyboard + }); + + const confirmationCtx = await conversation.waitForCallbackQuery([DELETE_CREATE, CONFIRM_CANCEL], { + otherwise: ctx => ctx.reply(ctx.t('use_buttons_alert'), { reply_markup: confirmationKeyboard }), + }); + + if (confirmationCtx.match === CONFIRM_CANCEL) + return await ctx.reply('👍'); + + await Monitor.findByIdAndDelete(id); + await ctx.reply(ctx.t('monitor_deleted')); + } catch (err) { + logger.debug('deleteMonitor')(err); + + await ctx.reply('Sorry, there was an error on our side. Please try again later or contact administrator.'); + } + }; +}; \ No newline at end of file diff --git a/app/bot/conversations/index.js b/app/bot/conversations/index.js index 6a9c3dd..bf652c3 100644 --- a/app/bot/conversations/index.js +++ b/app/bot/conversations/index.js @@ -1,7 +1,9 @@ const searchConversation = require('./search'); const addMonitorConversation = require('./addMonitor'); +const deleteMonitorConversation = require('./deleteMonitor'); module.exports = { searchConversation, - addMonitorConversation + addMonitorConversation, + deleteMonitorConversation } \ No newline at end of file diff --git a/app/bot/conversations/search.js b/app/bot/conversations/search.js index 5331f2d..8a732fa 100644 --- a/app/bot/conversations/search.js +++ b/app/bot/conversations/search.js @@ -29,14 +29,21 @@ module.exports = options => bot => { const onAddMonitor = async (id, coin, price, ctx) => { ctx.state = { id, coin, price }; - await ctx.conversation.enter('addMonitorConversation'); + const stats = await ctx.conversation.active(); + + if (!Object.keys(stats).length) + await ctx.conversation.enter('addMonitorConversation'); }; - const onShowMonitor = (coin, ctx) => { - ctx.reply(`Show Monitor for ${coin}`); + const onDeleteMonitor = async (monitor, ctx) => { + ctx.state = { id: monitor?.id }; + const stats = await ctx.conversation.active(); + + if (!Object.keys(stats).length) + await ctx.conversation.enter('deleteMonitorConversation'); }; - marketDetails.init(bot, { onAddMonitor, onShowMonitor }); + marketDetails.init(bot, { onAddMonitor, onDeleteMonitor }); pagination.init(bot, { onItemClick, renderItem: i => `${i.getName()} (${i.getCoin()})`, diff --git a/app/bot/keyboards/MarketDetailsKeyboard.js b/app/bot/keyboards/MarketDetailsKeyboard.js index 8823239..edcb3d6 100644 --- a/app/bot/keyboards/MarketDetailsKeyboard.js +++ b/app/bot/keyboards/MarketDetailsKeyboard.js @@ -9,7 +9,7 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { /** * @param {import('grammy').Bot} bot */ - init(bot, { onAddMonitor, onShowMonitor }) { + init(bot, { onAddMonitor, onDeleteMonitor }) { bot.on("callback_query:data", async (ctx, next) => { if (ctx.callbackQuery.data.includes('addMonitor')) { if (!this.#canAddMonitor(ctx.chat.id)) @@ -22,9 +22,9 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { await onAddMonitor(market.getId(), market.getCoin(), market.getPrice(true), ctx); } - if (ctx.callbackQuery.data.includes('showMonitor:')) { - const [, id] = ctx.callbackQuery.data.split(':'); - await onShowMonitor(id, ctx); + if (ctx.callbackQuery.data.includes('deleteMonitor')) { + const monitor = this.getMarketMonitor(ctx.chat.id); + await onDeleteMonitor(monitor, ctx); } await next(); @@ -114,7 +114,7 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { if (!existingMonitor) keyboard.text(ctx.t('add_monitor'), 'addMonitor'); else - keyboard.text(ctx.t('delete_monitor'), `deleteMonitor:${existingMonitor._id}`); + keyboard.text(ctx.t('delete_monitor'), 'deleteMonitor'); return keyboard; } diff --git a/app/bot/locales/en.ftl b/app/bot/locales/en.ftl index 2efcf05..939c028 100644 --- a/app/bot/locales/en.ftl +++ b/app/bot/locales/en.ftl @@ -41,4 +41,10 @@ monitor_threshold_prompt = Enter the price change threshold ({ $type }): monitor_number_validation = Only numbers, e.g. 12 or 1.4. Max: 1,000,000 -monitor_created = Monitor successfully created. \ No newline at end of file +monitor_created = Monitor successfully created. + +monitor_deleted = Monitor has been deleted. + +monitor_bad_threshold = Cannot create monitor with this threshold value. + +are_you_sure = Are you sure? \ No newline at end of file diff --git a/app/bot/locales/ru.ftl b/app/bot/locales/ru.ftl index 7de0d98..df89d9e 100644 --- a/app/bot/locales/ru.ftl +++ b/app/bot/locales/ru.ftl @@ -41,4 +41,10 @@ monitor_threshold_prompt = Укажите порог изменения цены monitor_number_validation = Только числа, например 12 или 1.4. Максимум: 1,000,000 -monitor_created = Монитор успешно создан. \ No newline at end of file +monitor_created = Монитор успешно создан. + +monitor_deleted = Монитор удален. + +monitor_bad_threshold = Невозможно создать монитор с таким значением порога. + +are_you_sure = Вы уверены? \ No newline at end of file diff --git a/app/bot/locales/uk.ftl b/app/bot/locales/uk.ftl index 8f3beab..cf571b1 100644 --- a/app/bot/locales/uk.ftl +++ b/app/bot/locales/uk.ftl @@ -41,4 +41,10 @@ monitor_threshold_prompt = Вкажіть поріг зміни ціни ({ $typ monitor_number_validation = Тільки числа, наприклад 12 або 1.4. Максимум: 1,000,000 -monitor_created = Монітор успішно створений. \ No newline at end of file +monitor_created = Монітор успішно створений. + +monitor_deleted = Монитор було видалено. + +monitor_bad_threshold = Неможливо створити монітор з таким значенням порогу. + +are_you_sure = Ви впевнені? \ No newline at end of file diff --git a/app/config.js b/app/config.js index 7d62cf7..3910cc0 100644 --- a/app/config.js +++ b/app/config.js @@ -1,5 +1,7 @@ module.exports = { - dbConnectionString: process.env.DB_CONNECTION_STRING, + dbConnectionString: process.env.DB_CONNECTION_STRING ?? 'mongodb://localhost:27017/coinMonitor', telegramBotToken: process.env.BOT_TOKEN, - wciApiKey: process.env.WCI_KEY + wciApiKey: process.env.WCI_KEY, + amqpConnectionString: process.env.AMQP_CONNECTION_STRING ?? 'amqp://localhost', + amqpQueueName: process.env.AMQP_QUEUE_NAME ?? 'marketChanges' }; \ No newline at end of file diff --git a/app/container.js b/app/container.js index f68c592..69bd0e6 100644 --- a/app/container.js +++ b/app/container.js @@ -5,8 +5,9 @@ const logger = require('./logger'); const { connectToDatabase, mongoose } = require('./db/database'); const App = require('./app'); const MarketsService = require('./market/MarketsService'); +const MarketChangesService = require('./market/MarketChangesService'); const TelegramBot = require('./bot/TelegramBot'); -const { searchConversation, addMonitorConversation } = require('./bot/conversations'); +const { searchConversation, addMonitorConversation, deleteMonitorConversation } = require('./bot/conversations'); const InlinePaginationKeyboard = require('./bot/keyboards/InlinePaginationKeyboard'); const MarketDetailsKeyboard = require('./bot/keyboards/MarketDetailsKeyboard'); @@ -21,9 +22,11 @@ container.register({ db: asFunction(connectToDatabase).singleton(), app: asClass(App), marketsService: asClass(MarketsService).singleton(), + marketChangesService: asClass(MarketChangesService).singleton(), telegramBot: asClass(TelegramBot).singleton(), conversationSearch: asFunction(searchConversation).singleton(), conversationAddMonitor: asFunction(addMonitorConversation).singleton(), + conversationDeleteMonitor: asFunction(deleteMonitorConversation).singleton(), inlinePaginationKeyboard: asClass(InlinePaginationKeyboard).singleton(), marketDetailsKeyboard: asClass(MarketDetailsKeyboard).singleton() }); diff --git a/app/market/MarketChangesService.js b/app/market/MarketChangesService.js new file mode 100644 index 0000000..a9a0d9f --- /dev/null +++ b/app/market/MarketChangesService.js @@ -0,0 +1,33 @@ +const AMQP = require('../amqp'); + +class MarketChangesService extends AMQP { + #queueName; + + constructor({ config }) { + super(config.amqpConnectionString); + this.#queueName = config.amqpQueueName; + } + + /** + * @param {import('./Market')} market + * @returns {Buffer} + */ + #formatMessage(market) { + return Buffer.from(`${market.getId()}:${market.getPrice()}`); + } + + /** + * @param {import('./Market')} market + */ + async push(market) { + const channel = await this.getChannel(this.#queueName); + + channel.sendToQueue(this.#queueName, this.#formatMessage(market), { + persistent: true + }); + + await channel.waitForConfirms(); + } +} + +module.exports = MarketChangesService; \ No newline at end of file diff --git a/app/market/MarketsService.js b/app/market/MarketsService.js index a0dce21..9257c4b 100644 --- a/app/market/MarketsService.js +++ b/app/market/MarketsService.js @@ -2,11 +2,13 @@ const round = require("../utils/round"); const Market = require("./Market"); const apiUrl = 'https://www.worldcoinindex.com/apiservice/v2getmarkets'; -const fetchInterval = 300_000; +const fetchInterval = 60_000; class MarketsService { #log; #apiKey; + + /** @type {(changes: Array) => void} */ #onChange; #markets = []; @@ -17,11 +19,15 @@ class MarketsService { this.#log('Initializing...'); } + /** + * @param {(changes: Array) => void} onChange + */ async watch(onChange) { try { if (onChange && !this.#onChange) this.#onChange = onChange; + /** @type {Array} */ const changes = []; const marketsData = await this.#fetch(); diff --git a/app/package-lock.json b/app/package-lock.json index bbc9181..7f372a8 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@grammyjs/conversations": "^1.2.0", "@grammyjs/i18n": "^1.0.2", + "amqplib": "^0.10.4", "awilix": "^10.0.2", "debug": "^4.3.5", "dotenv": "^16.4.5", @@ -23,6 +24,24 @@ "mocha": "^10.5.2" } }, + "node_modules/@acuminous/bitsyntax": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz", + "integrity": "sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ==", + "dependencies": { + "buffer-more-ints": "~1.0.0", + "debug": "^4.3.4", + "safe-buffer": "~5.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@acuminous/bitsyntax/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -364,6 +383,20 @@ "node": ">=6.5" } }, + "node_modules/amqplib": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.4.tgz", + "integrity": "sha512-DMZ4eCEjAVdX1II2TfIUpJhfKAuoCeDIo/YyETbfAqehHTXxxs7WOOd+N1Xxr4cKhx12y23zk8/os98FxlZHrw==", + "dependencies": { + "@acuminous/bitsyntax": "^0.1.2", + "buffer-more-ints": "~1.0.0", + "readable-stream": "1.x >=1.1.9", + "url-parse": "~1.5.10" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -480,6 +513,11 @@ "node": ">=16.20.1" } }, + "node_modules/buffer-more-ints": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", + "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" + }, "node_modules/camel-case": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", @@ -585,6 +623,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -864,8 +907,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -936,6 +978,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, "node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -1495,6 +1542,11 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1529,6 +1581,17 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1550,6 +1613,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -1653,6 +1721,11 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1736,6 +1809,15 @@ "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", "dev": true }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/app/package.json b/app/package.json index b7e269b..f709319 100644 --- a/app/package.json +++ b/app/package.json @@ -5,13 +5,14 @@ "main": "index.js", "scripts": { "test": "mocha **/*.test.js", - "start": "DEBUG=* DB_CONNECTION_STRING=mongodb://localhost:27017/coinMonitor node ./index.js" + "start": "DEBUG=* node ./index.js" }, "author": "Anton Korotkov", "license": "ISC", "dependencies": { "@grammyjs/conversations": "^1.2.0", "@grammyjs/i18n": "^1.0.2", + "amqplib": "^0.10.4", "awilix": "^10.0.2", "debug": "^4.3.5", "dotenv": "^16.4.5", diff --git a/docker-compose.yml b/docker-compose.yml index e5f7583..1c74b74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,9 @@ services: rabbitmq: container_name: RabbitMQ image: rabbitmq:management - # ports: - # - "5672:5672" - # - "15672:15672" + ports: + - "5672:5672" + - "15672:15672" networks: - app-network diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..0ec3f76 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /usr/src/worker + +COPY package*.json ./ + +RUN npm install + +COPY . . + +ENV DEBUG=* + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/worker/db/database.js b/worker/db/database.js new file mode 100644 index 0000000..b74d966 --- /dev/null +++ b/worker/db/database.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); +const debug = require('debug')('DB'); + +const connectToDatabase = async (dbConnectionString) => { + if (mongoose.connection.readyState !== 1) { + debug('Connecting...'); + await mongoose.connect(dbConnectionString); + } +}; + +module.exports = { + connectToDatabase, + mongoose +}; \ No newline at end of file diff --git a/worker/db/schemas/Monitor.js b/worker/db/schemas/Monitor.js new file mode 100644 index 0000000..9923b49 --- /dev/null +++ b/worker/db/schemas/Monitor.js @@ -0,0 +1,47 @@ +const mongoose = require('mongoose'); +const { Schema, model } = mongoose; + +const MonitorSchema = new Schema({ + telegramId: { + type: String, + immutable: true, + required: true + }, + coinId: { + type: String, + required: true + }, + coin: { + type: String, + required: true + }, + lastPrice: { + type: Number, + required: true + }, + threshold: { + value: { + type: Number, + required: true + }, + type: { + type: String, + enum: ['percentage', 'fixed'], + required: true + } + } +}); + +MonitorSchema.statics.createOrUpdate = function ({ + telegramId, coinId, ...data +}) { + return this.findOneAndUpdate( + { telegramId, coinId }, + { $set: data }, + { new: true, upsert: true } + ); +}; + +const Monitor = model('Monitor', MonitorSchema); + +module.exports = Monitor; \ No newline at end of file diff --git a/worker/db/schemas/User.js b/worker/db/schemas/User.js new file mode 100644 index 0000000..d65c9ed --- /dev/null +++ b/worker/db/schemas/User.js @@ -0,0 +1,47 @@ +const mongoose = require('mongoose'); +const { Schema, model } = mongoose; + +const UserSchema = new Schema({ + telegramId: { + type: String, + unique: true, + immutable: true + }, + nickname: { + type: String + }, + firstName: { + type: String + }, + lastName: { + type: String + }, + createdAt: { + type: Date, + default: Date.now + }, + isActive: { + type: Boolean + } +}); + +UserSchema.statics.createOrUpdate = function ({ + telegramId, nickname, firstName, lastName +}) { + return this.findOneAndUpdate( + { telegramId }, + { $set: { nickname, firstName, lastName, isActive: true }, $setOnInsert: { createdAt: Date.now() } }, + { new: true, upsert: true } + ); +}; + +UserSchema.statics.deactivate = function (telegramId) { + return this.findOneAndUpdate( + { telegramId }, + { $set: { isActive: false } } + ); +}; + +const User = model('User', UserSchema); + +module.exports = User; \ No newline at end of file diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..fe51a26 --- /dev/null +++ b/worker/index.js @@ -0,0 +1,50 @@ +require('dotenv').config(); +const amqp = require('amqplib'); +const { Api } = require('grammy'); +const debug = require('debug')('WORKER'); +const { connectToDatabase, mongoose } = require('./db/database'); +const Monitor = require('./db/schemas/Monitor'); + +const amqpConnectionString = process.env.AMQP_CONNECTION_STRING ?? 'amqp://localhost'; +const dbConnectionString = process.env.DB_CONNECTION_STRING ?? 'mongodb://localhost:27017/coinMonitor' +const queueName = process.env.AMQP_QUEUE_NAME ?? 'marketChanges'; +const apiToken = process.env.API_TOKEN; + +const start = async () => { + try { + const connection = await amqp.connect(amqpConnectionString); + const channel = await connection.createChannel(); + const api = new Api(apiToken); + + await channel.assertQueue(queueName, { + durable: true + }); + + channel.consume(queueName, async msg => { + if (!msg) + return; + + const messageString = msg.content.toString(); + const [coinId, priceString] = messageString.split(':'); + const price = parseFloat(priceString); + + const monitors = await Monitor.find({ coinId }); + + debug('Found: ' + coinId + ' - ' + price); + for (const monitor of monitors) { + await api.sendMessage(monitor.telegramId, "Yo!"); + } + + channel.ack(msg); + }); + } catch (error) { + debug(`Error: ${error.message}`); + } +}; + +mongoose.connection.once('open', () => { + debug('Connected to database.'); + start(); +}); + +connectToDatabase(dbConnectionString); \ No newline at end of file diff --git a/worker/package-lock.json b/worker/package-lock.json new file mode 100644 index 0000000..192ee29 --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,408 @@ +{ + "name": "worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worker", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "amqplib": "^0.10.4", + "debug": "^4.3.6", + "dotenv": "^16.4.5", + "grammy": "^1.29.0", + "mongoose": "^8.5.3" + } + }, + "node_modules/@acuminous/bitsyntax": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz", + "integrity": "sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ==", + "dependencies": { + "buffer-more-ints": "~1.0.0", + "debug": "^4.3.4", + "safe-buffer": "~5.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@grammyjs/types": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.13.0.tgz", + "integrity": "sha512-Oyq6fBuVPyX6iWvxT/0SxJvNisC9GHUEkhZ60qJBHRmwNX4hIcOfhrNEahicn3K9SYyreGPVw3d9wlLRds83cw==" + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.8.tgz", + "integrity": "sha512-qKwC/M/nNNaKUBMQ0nuzm47b7ZYWQHN3pcXq4IIcoSBc2hOIrflAxJduIvvqmhoz3gR2TacTAs8vlsCVPkiEdQ==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/amqplib": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.4.tgz", + "integrity": "sha512-DMZ4eCEjAVdX1II2TfIUpJhfKAuoCeDIo/YyETbfAqehHTXxxs7WOOd+N1Xxr4cKhx12y23zk8/os98FxlZHrw==", + "dependencies": { + "@acuminous/bitsyntax": "^0.1.2", + "buffer-more-ints": "~1.0.0", + "readable-stream": "1.x >=1.1.9", + "url-parse": "~1.5.10" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/bson": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz", + "integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-more-ints": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", + "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/grammy": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.29.0.tgz", + "integrity": "sha512-lj/6K6TGmVAdOpHj0PVFK7N37EGe76bpkbgvN+yqCqXYBIwuQosTe7qLhCls7/4pbDxf2+UVSqSXcOILgGGKWQ==", + "dependencies": { + "@grammyjs/types": "3.13.0", + "abort-controller": "^3.0.0", + "debug": "^4.3.4", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "node_modules/mongodb": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.7.0.tgz", + "integrity": "sha512-TMKyHdtMcO0fYBNORiYdmM25ijsHs+Njs963r4Tro4OQZzqYigAzYQouwWRg4OIaiLRUEGUh/1UAcH5lxdSLIA==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.7.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.5.3.tgz", + "integrity": "sha512-OubSDbsAclDFGHjV82MsKyIGQWFc42Ot1l+0dhRS6U9xODM7rm/ES/WpOQd8Ds9j0Mx8QzxZtrSCnBh6o9wUqw==", + "dependencies": { + "bson": "^6.7.0", + "kareem": "2.6.3", + "mongodb": "6.7.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + } + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..3ffa814 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,18 @@ +{ + "name": "worker", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "DEBUG=* node ./index.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "amqplib": "^0.10.4", + "debug": "^4.3.6", + "dotenv": "^16.4.5", + "grammy": "^1.29.0", + "mongoose": "^8.5.3" + } +} From c38716a52e1ab45e07d33ca5fba253fe6629d18d Mon Sep 17 00:00:00 2001 From: Anton Korotkov Date: Sun, 18 Aug 2024 21:19:32 +0200 Subject: [PATCH 3/5] fix test runner --- app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/package.json b/app/package.json index f709319..bb7b278 100644 --- a/app/package.json +++ b/app/package.json @@ -4,7 +4,7 @@ "description": "Telegram Bot for monitoring crypto coins prices", "main": "index.js", "scripts": { - "test": "mocha **/*.test.js", + "test": "mocha **/*.test.js --ignore '**/node_modules/**/*'", "start": "DEBUG=* node ./index.js" }, "author": "Anton Korotkov", From 2dec87e2516af6badc0fa8999fb98b998b2913a4 Mon Sep 17 00:00:00 2001 From: Anton Korotkov Date: Fri, 23 Aug 2024 21:49:10 +0200 Subject: [PATCH 4/5] done --- .env-sample | 2 + app/.env-sample | 4 ++ app/bot/conversations/addMonitor.js | 1 + app/bot/conversations/search.js | 4 +- app/bot/keyboards/MarketDetailsKeyboard.js | 2 +- app/db/schemas/Monitor.js | 4 ++ app/market/MarketsService.js | 2 +- docker-compose.yml | 67 +++++++++++++++++----- worker/.env-sample | 3 + worker/db/schemas/Monitor.js | 52 ++++++++++++++--- worker/db/schemas/User.js | 47 --------------- worker/index.js | 15 ++++- worker/utils/round.js | 5 ++ 13 files changed, 132 insertions(+), 76 deletions(-) create mode 100644 .env-sample create mode 100644 app/.env-sample create mode 100644 worker/.env-sample delete mode 100644 worker/db/schemas/User.js create mode 100644 worker/utils/round.js diff --git a/.env-sample b/.env-sample new file mode 100644 index 0000000..209307e --- /dev/null +++ b/.env-sample @@ -0,0 +1,2 @@ +RABBITMQ_DEFAULT_USER= +RABBITMQ_DEFAULT_PASS= \ No newline at end of file diff --git a/app/.env-sample b/app/.env-sample new file mode 100644 index 0000000..9e98f46 --- /dev/null +++ b/app/.env-sample @@ -0,0 +1,4 @@ +DB_CONNECTION_STRING=mongodb://mongodb:27017/coinMonitor +BOT_TOKEN= +WCI_KEY= +AMQP_CONNECTION_STRING=amqp://<>:<>@rabbitmq \ No newline at end of file diff --git a/app/bot/conversations/addMonitor.js b/app/bot/conversations/addMonitor.js index b8b2cc4..a49222b 100644 --- a/app/bot/conversations/addMonitor.js +++ b/app/bot/conversations/addMonitor.js @@ -49,6 +49,7 @@ module.exports = ({ logger }) => _ => { telegramId: ctx.chat.id, coinId: ctx.state.id, coin: ctx.state.coin, + coinName: ctx.state.name, lastPrice: ctx.state.price, threshold: { type: monitorTypeCtx.match, diff --git a/app/bot/conversations/search.js b/app/bot/conversations/search.js index 8a732fa..502e3d6 100644 --- a/app/bot/conversations/search.js +++ b/app/bot/conversations/search.js @@ -27,8 +27,8 @@ module.exports = options => bot => { }); } - const onAddMonitor = async (id, coin, price, ctx) => { - ctx.state = { id, coin, price }; + const onAddMonitor = async (id, coin, price, name, ctx) => { + ctx.state = { id, coin, price, name }; const stats = await ctx.conversation.active(); if (!Object.keys(stats).length) diff --git a/app/bot/keyboards/MarketDetailsKeyboard.js b/app/bot/keyboards/MarketDetailsKeyboard.js index edcb3d6..20512d8 100644 --- a/app/bot/keyboards/MarketDetailsKeyboard.js +++ b/app/bot/keyboards/MarketDetailsKeyboard.js @@ -19,7 +19,7 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { if (!market) return await ctx.reply('Oops... Looks like you were thinking for too long. Try again from search.'); - await onAddMonitor(market.getId(), market.getCoin(), market.getPrice(true), ctx); + await onAddMonitor(market.getId(), market.getCoin(), market.getPrice(true), market.getName(), ctx); } if (ctx.callbackQuery.data.includes('deleteMonitor')) { diff --git a/app/db/schemas/Monitor.js b/app/db/schemas/Monitor.js index 9923b49..b3dbbf2 100644 --- a/app/db/schemas/Monitor.js +++ b/app/db/schemas/Monitor.js @@ -15,6 +15,10 @@ const MonitorSchema = new Schema({ type: String, required: true }, + coinName: { + type: String, + required: true + }, lastPrice: { type: Number, required: true diff --git a/app/market/MarketsService.js b/app/market/MarketsService.js index 9257c4b..b616209 100644 --- a/app/market/MarketsService.js +++ b/app/market/MarketsService.js @@ -2,7 +2,7 @@ const round = require("../utils/round"); const Market = require("./Market"); const apiUrl = 'https://www.worldcoinindex.com/apiservice/v2getmarkets'; -const fetchInterval = 60_000; +const fetchInterval = 120_000; class MarketsService { #log; diff --git a/docker-compose.yml b/docker-compose.yml index 1c74b74..8d7d785 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,46 +3,83 @@ services: container_name: RabbitMQ image: rabbitmq:management ports: - - "5672:5672" - - "15672:15672" + - "15677:15672" + environment: + RABBITMQ_DEFAULT_USER: + RABBITMQ_DEFAULT_PASS: networks: - app-network + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 5s + timeout: 5s + retries: 5 + logging: + driver: json-file + options: + max-size: 1m + max-file: 3 mongodb: image: mongo:latest container_name: MongoDB ports: - - "27017:27017" + - "27077:27017" networks: - app-network volumes: - mongo-data:/data/db + logging: + driver: json-file + options: + max-size: 1m + max-file: 3 app: container_name: TelegramBot depends_on: - - mongodb - - rabbitmq + rabbitmq: + condition: service_healthy + mongodb: + condition: service_started build: context: ./app dockerfile: Dockerfile environment: DB_CONNECTION_STRING: + AMQP_CONNECTION_STRING: BOT_TOKEN: WCI_KEY: networks: - app-network + logging: + driver: json-file + options: + max-size: 1m + max-file: 3 - # worker: - # build: - # context: . - # dockerfile: Dockerfile - # environment: - # RABBITMQ_URL: amqp://rabbitmq - # networks: - # - app-network - # deploy: - # replicas: 3 + worker: + depends_on: + rabbitmq: + condition: service_healthy + mongodb: + condition: service_started + build: + context: ./worker + dockerfile: Dockerfile + environment: + AMQP_CONNECTION_STRING: + DB_CONNECTION_STRING: + API_TOKEN: + networks: + - app-network + deploy: + replicas: 3 + logging: + driver: json-file + options: + max-size: 1m + max-file: 3 networks: app-network: diff --git a/worker/.env-sample b/worker/.env-sample new file mode 100644 index 0000000..d09da7a --- /dev/null +++ b/worker/.env-sample @@ -0,0 +1,3 @@ +AMQP_CONNECTION_STRING=amqp://rabbitmq +DB_CONNECTION_STRING=mongodb://mongodb:27017/coinMonitor +API_TOKEN= \ No newline at end of file diff --git a/worker/db/schemas/Monitor.js b/worker/db/schemas/Monitor.js index 9923b49..89ad620 100644 --- a/worker/db/schemas/Monitor.js +++ b/worker/db/schemas/Monitor.js @@ -15,6 +15,10 @@ const MonitorSchema = new Schema({ type: String, required: true }, + coinName: { + type: String, + required: true + }, lastPrice: { type: Number, required: true @@ -32,14 +36,46 @@ const MonitorSchema = new Schema({ } }); -MonitorSchema.statics.createOrUpdate = function ({ - telegramId, coinId, ...data -}) { - return this.findOneAndUpdate( - { telegramId, coinId }, - { $set: data }, - { new: true, upsert: true } - ); +/** + * @param {{ type: 'percentage' | 'fixed', value: number }} threshold + * @param {number} lastPrice + * @returns {number} + */ +const getExpectedDiff = ({ type, value }, lastPrice) => { + if (type === 'fixed') + return value; + + if (type === 'percentage') { + return lastPrice / 100 * value; + } + + return 0; +}; + +/** + * @param {number} newPrice + * @returns {boolean} + */ +MonitorSchema.methods.shouldNotify = function (newPrice) { + const monitorObject = this.toObject(); + const expectedDiff = getExpectedDiff(monitorObject.threshold, monitorObject.lastPrice); + + if (!expectedDiff) + return false; + + if (Math.abs(newPrice - monitorObject.lastPrice) >= expectedDiff) + return true; + + return false; +}; + +/** + * @param {number} price + */ +MonitorSchema.methods.updateLastPrice = function (price) { + return this.updateOne({ + lastPrice: price + }); }; const Monitor = model('Monitor', MonitorSchema); diff --git a/worker/db/schemas/User.js b/worker/db/schemas/User.js deleted file mode 100644 index d65c9ed..0000000 --- a/worker/db/schemas/User.js +++ /dev/null @@ -1,47 +0,0 @@ -const mongoose = require('mongoose'); -const { Schema, model } = mongoose; - -const UserSchema = new Schema({ - telegramId: { - type: String, - unique: true, - immutable: true - }, - nickname: { - type: String - }, - firstName: { - type: String - }, - lastName: { - type: String - }, - createdAt: { - type: Date, - default: Date.now - }, - isActive: { - type: Boolean - } -}); - -UserSchema.statics.createOrUpdate = function ({ - telegramId, nickname, firstName, lastName -}) { - return this.findOneAndUpdate( - { telegramId }, - { $set: { nickname, firstName, lastName, isActive: true }, $setOnInsert: { createdAt: Date.now() } }, - { new: true, upsert: true } - ); -}; - -UserSchema.statics.deactivate = function (telegramId) { - return this.findOneAndUpdate( - { telegramId }, - { $set: { isActive: false } } - ); -}; - -const User = model('User', UserSchema); - -module.exports = User; \ No newline at end of file diff --git a/worker/index.js b/worker/index.js index fe51a26..a3c8dda 100644 --- a/worker/index.js +++ b/worker/index.js @@ -4,12 +4,21 @@ const { Api } = require('grammy'); const debug = require('debug')('WORKER'); const { connectToDatabase, mongoose } = require('./db/database'); const Monitor = require('./db/schemas/Monitor'); +const round = require('./utils/round'); const amqpConnectionString = process.env.AMQP_CONNECTION_STRING ?? 'amqp://localhost'; const dbConnectionString = process.env.DB_CONNECTION_STRING ?? 'mongodb://localhost:27017/coinMonitor' const queueName = process.env.AMQP_QUEUE_NAME ?? 'marketChanges'; const apiToken = process.env.API_TOKEN; +const formatMessage = (monitor, price) => { + const dir = price > monitor.lastPrice ? '🟢' : '🔴'; + const diff = round(price - monitor.lastPrice); + const change = diff > 0 ? `+$${diff}` : `-$${Math.abs(diff)}`; + + return `${dir} ${monitor.coinName} (${monitor.coin}): $${Intl.NumberFormat().format(price)} (${change})`; +}; + const start = async () => { try { const connection = await amqp.connect(amqpConnectionString); @@ -30,12 +39,14 @@ const start = async () => { const monitors = await Monitor.find({ coinId }); - debug('Found: ' + coinId + ' - ' + price); for (const monitor of monitors) { - await api.sendMessage(monitor.telegramId, "Yo!"); + const shouldNotify = monitor.shouldNotify(price); + if (shouldNotify && await api.sendMessage(monitor.telegramId, formatMessage(monitor, price), {parse_mode: 'HTML'})) + await monitor.updateLastPrice(price); } channel.ack(msg); + debug('Message processed successfully'); }); } catch (error) { debug(`Error: ${error.message}`); diff --git a/worker/utils/round.js b/worker/utils/round.js new file mode 100644 index 0000000..54f35c7 --- /dev/null +++ b/worker/utils/round.js @@ -0,0 +1,5 @@ +const round = num => { + return Math.ceil(num * 10000) / 10000; +} + +module.exports = round; \ No newline at end of file From d976e05603ccce37fe323836db6abef5c21af85b Mon Sep 17 00:00:00 2001 From: Anton Korotkov Date: Fri, 23 Aug 2024 22:13:38 +0200 Subject: [PATCH 5/5] locals --- app/bot/conversations/addMonitor.js | 4 ++-- app/bot/conversations/deleteMonitor.js | 2 +- app/bot/keyboards/MarketDetailsKeyboard.js | 2 +- app/bot/locales/en.ftl | 12 +++++++++++- app/bot/locales/ru.ftl | 12 +++++++++++- app/bot/locales/uk.ftl | 12 +++++++++++- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/bot/conversations/addMonitor.js b/app/bot/conversations/addMonitor.js index a49222b..8fd9544 100644 --- a/app/bot/conversations/addMonitor.js +++ b/app/bot/conversations/addMonitor.js @@ -31,8 +31,8 @@ module.exports = ({ logger }) => _ => { return await ctx.reply(ctx.t('monitor_bad_threshold')); const confirmationKeyboard = new InlineKeyboard(); - confirmationKeyboard.text('Create', CONFIRM_CREATE).text('Cancel', CONFIRM_CANCEL); - await ctx.reply(`You are about to create a ${monitorTypeCtx.match} price monitor for ${ctx.state.coin} with the threshold of ${value}${monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : '$'}`, { + confirmationKeyboard.text(ctx.t('create'), CONFIRM_CREATE).text(ctx.t('cancel'), CONFIRM_CANCEL); + await ctx.reply(ctx.t('add_monitor_confirmation', { type: monitorTypeCtx.match, coin: ctx.state.coin, value: `${value}${monitorTypeCtx.match === TYPE_PERCENTAGE ? '%' : '$'}` }), { parse_mode: 'HTML', reply_markup: confirmationKeyboard }); diff --git a/app/bot/conversations/deleteMonitor.js b/app/bot/conversations/deleteMonitor.js index 28d18c6..29f909d 100644 --- a/app/bot/conversations/deleteMonitor.js +++ b/app/bot/conversations/deleteMonitor.js @@ -13,7 +13,7 @@ module.exports = ({ logger }) => _ => { if (!id) return await ctx.reply('Something went wrong. No monitor selected for deletion.'); - confirmationKeyboard.text('Delete', DELETE_CREATE).text('Cancel', CONFIRM_CANCEL); + confirmationKeyboard.text(ctx.t('delete'), DELETE_CREATE).text(ctx.t('cancel'), CONFIRM_CANCEL); await ctx.reply(ctx.t('are_you_sure'), { reply_markup: confirmationKeyboard }); diff --git a/app/bot/keyboards/MarketDetailsKeyboard.js b/app/bot/keyboards/MarketDetailsKeyboard.js index 20512d8..e6e713b 100644 --- a/app/bot/keyboards/MarketDetailsKeyboard.js +++ b/app/bot/keyboards/MarketDetailsKeyboard.js @@ -13,7 +13,7 @@ class MarketDetailsKeyboard extends AbstractPersonalizedCache { bot.on("callback_query:data", async (ctx, next) => { if (ctx.callbackQuery.data.includes('addMonitor')) { if (!this.#canAddMonitor(ctx.chat.id)) - return await ctx.reply('You cannot add more than 5 monitors.'); + return await ctx.reply(ctx.t('monitors_limit')); const market = this.#getMarket(ctx.chat.id); if (!market) diff --git a/app/bot/locales/en.ftl b/app/bot/locales/en.ftl index 939c028..3335c3f 100644 --- a/app/bot/locales/en.ftl +++ b/app/bot/locales/en.ftl @@ -47,4 +47,14 @@ monitor_deleted = Monitor has been deleted. monitor_bad_threshold = Cannot create monitor with this threshold value. -are_you_sure = Are you sure? \ No newline at end of file +are_you_sure = Are you sure? + +delete = Delete + +create = Create + +cancel = Cancel + +add_monitor_confirmation = You are about to create a { $type } price monitor for { $coin } with the threshold of { $value } + +monitors_limit = You cannot add more than 5 monitors \ No newline at end of file diff --git a/app/bot/locales/ru.ftl b/app/bot/locales/ru.ftl index df89d9e..5a84199 100644 --- a/app/bot/locales/ru.ftl +++ b/app/bot/locales/ru.ftl @@ -47,4 +47,14 @@ monitor_deleted = Монитор удален. monitor_bad_threshold = Невозможно создать монитор с таким значением порога. -are_you_sure = Вы уверены? \ No newline at end of file +are_you_sure = Вы уверены? + +delete = Удалить + +create = Создать + +cancel = Отмена + +add_monitor_confirmation = Вы собираетесь создать ценовой монитор типа { $type } для { $coin } с порогом { $value } + +monitors_limit = Вы не можете иметь больше 5 мониторов \ No newline at end of file diff --git a/app/bot/locales/uk.ftl b/app/bot/locales/uk.ftl index cf571b1..ed92bee 100644 --- a/app/bot/locales/uk.ftl +++ b/app/bot/locales/uk.ftl @@ -47,4 +47,14 @@ monitor_deleted = Монитор було видалено. monitor_bad_threshold = Неможливо створити монітор з таким значенням порогу. -are_you_sure = Ви впевнені? \ No newline at end of file +are_you_sure = Ви впевнені? + +delete = Видалити + +create = Створити + +cancel = Відміна + +add_monitor_confirmation = Ви збираєтесь створити моніторинг ціни типу { $type } для { $coin } з порогом { $value } + +monitors_limit = Ви не можете мати більше 5 моніторів \ No newline at end of file