diff --git a/.env.sample b/.env.sample index a8dd54b..3b03803 100644 --- a/.env.sample +++ b/.env.sample @@ -67,8 +67,10 @@ TRONSCAN_WALLET_ADDRESS= # ---- Optional: shutdown announcement --------------------------------------- -# Set SHUTDOWN_MODE=true to silence every handler and reply with the farewell -# in src/middlewares/shutdownMode.ts. Unset to restore the bot. +# Set SHUTDOWN_MODE=true to silence every handler, reply to incoming updates +# with the farewell in src/middlewares/shutdownMode.ts, AND stop every +# cron-driven outbound notification (price alerts, shift alerts). +# Unset to restore the bot. SHUTDOWN_MODE= diff --git a/CONFIG.md b/CONFIG.md index d0d2348..c263d61 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -49,7 +49,7 @@ simply disable that data source. | Variable | Description | |----------|-------------| -| `SHUTDOWN_MODE` | Set to `true` to silence every handler and reply with the farewell in `src/middlewares/shutdownMode.ts`. Unset to restore the bot. | +| `SHUTDOWN_MODE` | Set to `true` to silence every inbound handler (replies with the farewell in `src/middlewares/shutdownMode.ts`) **and** stop every cron-driven outbound notification (price and shift alerts). Unset to restore the bot. | ## Optional: MongoDB tuning diff --git a/src/cron/priceChecker/priceChecker.utils.test.ts b/src/cron/priceChecker/priceChecker.utils.test.ts new file mode 100644 index 0000000..31e15c7 --- /dev/null +++ b/src/cron/priceChecker/priceChecker.utils.test.ts @@ -0,0 +1,64 @@ +jest.mock('@/models', () => ({ + priceAlertCache: { removeItemFromCache: jest.fn() }, + removePriceAlert: jest.fn(), +})) +jest.mock('@/helpers/bot', () => ({ getBot: jest.fn() })) +jest.mock('@/helpers', () => ({ log: { error: jest.fn(), info: jest.fn() } })) +jest.mock('@/commands/alert/keyboards/triggeredAlert', () => ({ + triggeredAlertKeyboad: jest.fn(), +})) +jest.mock('@/commands/alert/messages/alert', () => ({ + alertMessage: jest.fn(), +})) + +import { getBot } from '@/helpers/bot' +import { priceAlertCache, removePriceAlert } from '@/models' + +import { sendTriggeredAlert } from './priceChecker.utils' + +const fakeAlert = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _id: { toString: () => 'alert-1' } as any, + user: 42, + chat: null, + botId: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any + +const fakeInstrument = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any + +describe('sendTriggeredAlert with SHUTDOWN_MODE', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('skips entirely when SHUTDOWN_MODE=true', async () => { + process.env.SHUTDOWN_MODE = 'true' + + await sendTriggeredAlert(fakeAlert, fakeInstrument) + + expect(priceAlertCache.removeItemFromCache).not.toHaveBeenCalled() + expect(getBot).not.toHaveBeenCalled() + expect(removePriceAlert).not.toHaveBeenCalled() + }) + + it('proceeds when SHUTDOWN_MODE is unset', async () => { + delete process.env.SHUTDOWN_MODE + // getBot resolves to null so we exit before attempting to send, + // but after we have proven the cache invalidation ran. + ;(getBot as jest.Mock).mockResolvedValue(null) + + await sendTriggeredAlert(fakeAlert, fakeInstrument) + + expect(priceAlertCache.removeItemFromCache).toHaveBeenCalledWith('alert-1') + expect(getBot).toHaveBeenCalledWith(1) + }) +}) diff --git a/src/cron/priceChecker/priceChecker.utils.ts b/src/cron/priceChecker/priceChecker.utils.ts index 6c9c26b..7237493 100644 --- a/src/cron/priceChecker/priceChecker.utils.ts +++ b/src/cron/priceChecker/priceChecker.utils.ts @@ -2,6 +2,7 @@ import { triggeredAlertKeyboad } from '@/commands/alert/keyboards/triggeredAlert import { alertMessage } from '@/commands/alert/messages/alert' import { log } from '@/helpers' import { getBot } from '@/helpers/bot' +import { isShutdownMode } from '@/helpers/isShutdownMode' import { InstrumentsList, PriceAlert, @@ -21,6 +22,11 @@ export const sendTriggeredAlert = async ( alert: PriceAlert, instrumentData: InstrumentsList ) => { + // Kill switch: stop pushing alerts as soon as SHUTDOWN_MODE is on. + // Skip before touching the cache / DB so the alert survives the freeze + // and would re-trigger if the bot is ever brought back. + if (isShutdownMode()) return + const { message: _message, lowerThen: _lowerThen, diff --git a/src/cron/shiftsChecker/shiftChecker.utils.test.ts b/src/cron/shiftsChecker/shiftChecker.utils.test.ts new file mode 100644 index 0000000..c1ac91e --- /dev/null +++ b/src/cron/shiftsChecker/shiftChecker.utils.test.ts @@ -0,0 +1,76 @@ +jest.mock('@/cron/shiftsChecker/shiftsChecker', () => ({ + shiftsCache: { update: jest.fn() }, +})) +jest.mock('./shiftChecker.keyboards', () => ({ + shiftAlertSettingsKeyboard: jest.fn(), +})) +jest.mock('@/helpers/bot', () => ({ getBot: jest.fn() })) +jest.mock('@/helpers/getLastPrice', () => ({ getLastPrice: jest.fn() })) +jest.mock('@/helpers/getSourceMark', () => ({ getSourceMark: jest.fn() })) +jest.mock('@/helpers/getSymbolByTicker', () => ({ + getSymbolByTicker: jest.fn(), +})) +jest.mock('@/models/Chat', () => ({ ChatModel: { updateOne: jest.fn() } })) +jest.mock('../../helpers', () => ({ + calcGrowPercent: jest.fn(), + getCandleCreatedTime: jest.fn(), +})) +jest.mock('../../helpers/i18n', () => ({ i18n: { t: jest.fn() } })) +jest.mock('../../helpers/log', () => ({ + log: { error: jest.fn(), info: jest.fn() }, +})) +jest.mock('../../models', () => ({ + getInstrumentByIdFromCache: jest.fn(), + TimeShiftModel: { updateOne: jest.fn(), remove: jest.fn() }, +})) + +import { calcGrowPercent } from '../../helpers' +import { checkTriggeredShiftsAndSendMessage } from './shiftChecker.utils' + +describe('checkTriggeredShiftsAndSendMessage with SHUTDOWN_MODE', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + const params = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + candle: { h: 110, l: 90, o: 100 } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shift: { + _id: 'shift-1', + ticker: 'BTCUSDT', + muted: false, + percent: 5, + growAlerts: true, + fallAlerts: true, + botId: 1, + user: 42, + chat: null, + } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + timeframeData: { name_ru_plur: '5 минут' } as any, + } + + it('short-circuits before any percent math when SHUTDOWN_MODE=true', async () => { + process.env.SHUTDOWN_MODE = 'true' + + await checkTriggeredShiftsAndSendMessage(params) + + expect(calcGrowPercent).not.toHaveBeenCalled() + }) + + it('runs the percent math when SHUTDOWN_MODE is unset', async () => { + delete process.env.SHUTDOWN_MODE + ;(calcGrowPercent as jest.Mock).mockReturnValue(0) + + await checkTriggeredShiftsAndSendMessage(params) + + expect(calcGrowPercent).toHaveBeenCalled() + }) +}) diff --git a/src/cron/shiftsChecker/shiftChecker.utils.ts b/src/cron/shiftsChecker/shiftChecker.utils.ts index 650fc4b..f12a6d4 100644 --- a/src/cron/shiftsChecker/shiftChecker.utils.ts +++ b/src/cron/shiftsChecker/shiftChecker.utils.ts @@ -4,6 +4,7 @@ import { getBot } from '@/helpers/bot' import { getLastPrice } from '@/helpers/getLastPrice' import { getSourceMark } from '@/helpers/getSourceMark' import { getSymbolByTicker } from '@/helpers/getSymbolByTicker' +import { isShutdownMode } from '@/helpers/isShutdownMode' import { ChatModel } from '@/models/Chat' import { calcGrowPercent, getCandleCreatedTime } from '../../helpers' @@ -99,6 +100,11 @@ export const checkTriggeredShiftsAndSendMessage = async ({ shift, timeframeData, }) => { + // Kill switch: do not push any shift alerts while SHUTDOWN_MODE is on. + // Returning before the percent math keeps the triggeredShiftsCache untouched + // so nothing leaks out when the flag is later removed. + if (isShutdownMode()) return + const growPercent = calcGrowPercent(candle.h, candle.o) const fallPercent = calcGrowPercent(candle.l, candle.o) diff --git a/src/helpers/isShutdownMode.test.ts b/src/helpers/isShutdownMode.test.ts new file mode 100644 index 0000000..49dd098 --- /dev/null +++ b/src/helpers/isShutdownMode.test.ts @@ -0,0 +1,31 @@ +import { isShutdownMode } from '@/helpers/isShutdownMode' + +describe('isShutdownMode', () => { + const originalEnv = process.env + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('returns false when SHUTDOWN_MODE is unset', () => { + delete process.env.SHUTDOWN_MODE + expect(isShutdownMode()).toBe(false) + }) + + it('returns true only when SHUTDOWN_MODE is exactly "true"', () => { + process.env.SHUTDOWN_MODE = 'true' + expect(isShutdownMode()).toBe(true) + }) + + it('rejects other truthy spellings to keep the kill switch explicit', () => { + for (const value of ['1', 'TRUE', 'yes', 'on', 'True']) { + process.env.SHUTDOWN_MODE = value + expect(isShutdownMode()).toBe(false) + } + }) + + it('returns false for empty string', () => { + process.env.SHUTDOWN_MODE = '' + expect(isShutdownMode()).toBe(false) + }) +}) diff --git a/src/helpers/isShutdownMode.ts b/src/helpers/isShutdownMode.ts new file mode 100644 index 0000000..02a947d --- /dev/null +++ b/src/helpers/isShutdownMode.ts @@ -0,0 +1,7 @@ +// Single source of truth for the SHUTDOWN_MODE kill switch. +// Enabled when process.env.SHUTDOWN_MODE === 'true'. Used by the inbound +// middleware (src/middlewares/shutdownMode.ts) and by every outbound +// notification path (cron alerts) so nothing reaches users while the +// bot is being wound down. +export const isShutdownMode = (): boolean => + process.env.SHUTDOWN_MODE === 'true' diff --git a/src/middlewares/shutdownMode.ts b/src/middlewares/shutdownMode.ts index 57b8518..8889616 100644 --- a/src/middlewares/shutdownMode.ts +++ b/src/middlewares/shutdownMode.ts @@ -1,5 +1,7 @@ import { Context } from 'telegraf' +import { isShutdownMode } from '@/helpers/isShutdownMode' + /* eslint-disable max-len */ export const SHUTDOWN_MESSAGE = `привет. я сделал гуся пять лет назад — тогда нигде не было удобных алертов по ценам. начал для себя, потом подтянулись люди. @@ -20,6 +22,6 @@ export const SHUTDOWN_MESSAGE = `привет. я сделал гуся пять // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function shutdownMode(ctx: Context, next: () => any) { - if (process.env.SHUTDOWN_MODE !== 'true') return next() + if (!isShutdownMode()) return next() await ctx.reply(SHUTDOWN_MESSAGE) }