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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
2 changes: 1 addition & 1 deletion CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions src/cron/priceChecker/priceChecker.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
6 changes: 6 additions & 0 deletions src/cron/priceChecker/priceChecker.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions src/cron/shiftsChecker/shiftChecker.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
6 changes: 6 additions & 0 deletions src/cron/shiftsChecker/shiftChecker.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions src/helpers/isShutdownMode.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 7 additions & 0 deletions src/helpers/isShutdownMode.ts
Original file line number Diff line number Diff line change
@@ -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'
4 changes: 3 additions & 1 deletion src/middlewares/shutdownMode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Context } from 'telegraf'

import { isShutdownMode } from '@/helpers/isShutdownMode'

/* eslint-disable max-len */
export const SHUTDOWN_MESSAGE = `привет. я сделал гуся пять лет назад — тогда нигде не было удобных алертов по ценам. начал для себя, потом подтянулись люди.

Expand All @@ -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)
}
Loading