From 6020ed64929e05c7984bd195063d5cfa3920afaf Mon Sep 17 00:00:00 2001 From: Aleksey Shugaev Date: Mon, 25 May 2026 10:30:34 +0000 Subject: [PATCH] Extend SHUTDOWN_MODE to block cron-driven user notifications The kill switch already intercepted incoming updates, but the priceChecker and shiftsChecker cron jobs kept pushing alerts to users. Add an isShutdownMode() helper and gate sendTriggeredAlert and checkTriggeredShiftsAndSendMessage so nothing reaches the user while the flag is on. The gate sits before any cache or DB mutation so alerts survive the freeze unchanged. --- .env.sample | 6 +- CONFIG.md | 2 +- .../priceChecker/priceChecker.utils.test.ts | 64 ++++++++++++++++ src/cron/priceChecker/priceChecker.utils.ts | 6 ++ .../shiftsChecker/shiftChecker.utils.test.ts | 76 +++++++++++++++++++ src/cron/shiftsChecker/shiftChecker.utils.ts | 6 ++ src/helpers/isShutdownMode.test.ts | 31 ++++++++ src/helpers/isShutdownMode.ts | 7 ++ src/middlewares/shutdownMode.ts | 4 +- 9 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 src/cron/priceChecker/priceChecker.utils.test.ts create mode 100644 src/cron/shiftsChecker/shiftChecker.utils.test.ts create mode 100644 src/helpers/isShutdownMode.test.ts create mode 100644 src/helpers/isShutdownMode.ts 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) }