← Back
// Telegram Price Alert Bot — freelance template
// Typical Upwork/Fiverr project: $200-500
// Stack: grammy + better-sqlite3 + Binance REST API

const { Bot } = require('grammy')
const Database = require('better-sqlite3')

// ---- Config ----
const BOT_TOKEN = process.env.BOT_TOKEN || 'YOUR_BOT_TOKEN'
const CHECK_INTERVAL = 15000 // check prices every 15s
const BINANCE_API = 'https://fapi.binance.com'

// ---- Database ----
const db = new Database('./alerts.db')
db.pragma('journal_mode = WAL')
db.exec(`
  CREATE TABLE IF NOT EXISTS alerts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chat_id INTEGER NOT NULL,
    symbol TEXT NOT NULL,
    direction TEXT NOT NULL CHECK(direction IN ('above', 'below')),
    target REAL NOT NULL,
    created_at TEXT DEFAULT (datetime('now')),
    triggered INTEGER DEFAULT 0
  )
`)

const stmts = {
  insert: db.prepare('INSERT INTO alerts (chat_id, symbol, direction, target) VALUES (?, ?, ?, ?)'),
  getByChat: db.prepare('SELECT * FROM alerts WHERE chat_id = ? AND triggered = 0 ORDER BY id'),
  getActive: db.prepare('SELECT * FROM alerts WHERE triggered = 0'),
  trigger: db.prepare('UPDATE alerts SET triggered = 1 WHERE id = ?'),
  delete: db.prepare('DELETE FROM alerts WHERE id = ? AND chat_id = ?'),
}

// ---- Binance Price Fetcher ----
async function getPrice(symbol) {
  const sym = symbol.toUpperCase().replace('USDT', '') + 'USDT'
  const resp = await fetch(`${BINANCE_API}/fapi/v1/ticker/price?symbol=${sym}`)
  if (!resp.ok) return null
  const data = await resp.json()
  return { symbol: data.symbol, price: parseFloat(data.price) }
}

async function getPrices(symbols) {
  const resp = await fetch(`${BINANCE_API}/fapi/v1/ticker/price`)
  if (!resp.ok) return new Map()
  const data = await resp.json()
  const map = new Map()
  for (const t of data) {
    map.set(t.symbol, parseFloat(t.price))
  }
  return map
}

// ---- Bot ----
const bot = new Bot(BOT_TOKEN)

bot.command('start', (ctx) => {
  ctx.reply(
    '🔔 *Price Alert Bot*\n\n' +
    'Commands:\n' +
    '`/price BTC` — current price\n' +
    '`/alert BTC above 70000` — set alert\n' +
    '`/alert ETH below 3000` — set alert\n' +
    '`/alerts` — view active alerts\n' +
    '`/delete 3` — remove alert by ID',
    { parse_mode: 'Markdown' }
  )
})

bot.command('price', async (ctx) => {
  const args = ctx.message.text.split(' ')
  if (args.length < 2) return ctx.reply('Usage: /price BTC')

  const coin = args[1].toUpperCase()
  const data = await getPrice(coin)
  if (!data) return ctx.reply(`❌ Symbol ${coin} not found`)

  ctx.reply(`💰 *${coin}*: $${data.price.toLocaleString('en-US')}`, { parse_mode: 'Markdown' })
})

bot.command('alert', (ctx) => {
  const args = ctx.message.text.split(' ')
  // /alert BTC above 70000
  if (args.length < 4) return ctx.reply('Usage: /alert BTC above 70000')

  const coin = args[1].toUpperCase()
  const direction = args[2].toLowerCase()
  const target = parseFloat(args[3])

  if (!['above', 'below'].includes(direction)) {
    return ctx.reply('❌ Direction must be `above` or `below`')
  }
  if (isNaN(target) || target <= 0) {
    return ctx.reply('❌ Invalid target price')
  }

  const symbol = coin.replace('USDT', '') + 'USDT'
  stmts.insert.run(ctx.chat.id, symbol, direction, target)

  const arrow = direction === 'above' ? '📈' : '📉'
  ctx.reply(`${arrow} Alert set: *${coin}* ${direction} $${target.toLocaleString('en-US')}`, { parse_mode: 'Markdown' })
})

bot.command('alerts', (ctx) => {
  const alerts = stmts.getByChat.all(ctx.chat.id)
  if (alerts.length === 0) return ctx.reply('No active alerts. Use /alert to create one.')

  const lines = alerts.map(a => {
    const coin = a.symbol.replace('USDT', '')
    const arrow = a.direction === 'above' ? '📈' : '📉'
    return `${arrow} #${a.id} *${coin}* ${a.direction} $${a.target.toLocaleString('en-US')}`
  })

  ctx.reply('🔔 *Active Alerts:*\n\n' + lines.join('\n'), { parse_mode: 'Markdown' })
})

bot.command('delete', (ctx) => {
  const args = ctx.message.text.split(' ')
  if (args.length < 2) return ctx.reply('Usage: /delete 3')

  const id = parseInt(args[1])
  if (isNaN(id)) return ctx.reply('❌ Invalid alert ID')

  const result = stmts.delete.run(id, ctx.chat.id)
  if (result.changes > 0) {
    ctx.reply(`✅ Alert #${id} deleted`)
  } else {
    ctx.reply(`❌ Alert #${id} not found`)
  }
})

// ---- Price Checker Loop ----
async function checkAlerts() {
  const alerts = stmts.getActive.all()
  if (alerts.length === 0) return

  const prices = await getPrices()

  for (const alert of alerts) {
    const price = prices.get(alert.symbol)
    if (!price) continue

    const triggered =
      (alert.direction === 'above' && price >= alert.target) ||
      (alert.direction === 'below' && price <= alert.target)

    if (triggered) {
      stmts.trigger.run(alert.id)
      const coin = alert.symbol.replace('USDT', '')
      const arrow = alert.direction === 'above' ? '🚀' : '🔻'

      try {
        await bot.api.sendMessage(
          alert.chat_id,
          `${arrow} *ALERT TRIGGERED!*\n\n` +
          `*${coin}* is now $${price.toLocaleString('en-US')}\n` +
          `Target: ${alert.direction} $${alert.target.toLocaleString('en-US')}`,
          { parse_mode: 'Markdown' }
        )
      } catch (err) {
        console.error(`[Alert] Failed to notify chat ${alert.chat_id}:`, err.message)
      }
    }
  }
}

// ---- Start ----
async function main() {
  console.log('[PriceAlertBot] Starting...')

  // Price check loop
  setInterval(checkAlerts, CHECK_INTERVAL)

  // Start bot
  await bot.start({
    onStart: () => console.log('[PriceAlertBot] Bot is running!'),
  })
}

main().catch(console.error)