Bot & Automation

Market AI

/root/hermes-projects/Market AI

apps/bot/src/index.ts text
import { Bot, InlineKeyboard, Keyboard } from "grammy";
import {
  formatAlertHelp,
  formatMainMenuCard,
  formatMemeSignalCard,
  formatScannerResults,
  formatTradeQuote,
  formatTradeResult,
  generateMemeSignalAnalysis,
  inferModeFromText,
  inferTokenQueryFromText,
  logger,
  tradeRequestSchema,
  type TradeQuote,
  type TradeSide,
  type TradingMode,
} from "@market-ai/core";
import { buildAiSignalReviewPrompt } from "@market-ai/prompts";
import { generateAiReview, isGithubModelsConfigured, memeMarketDataRouter, tradeExecutor } from "@market-ai/provider-adapters";

const token = process.env.TELEGRAM_BOT_TOKEN;

if (!token || token === "replace_with_telegram_bot_token") {
  logger.error("telegram_token_missing", {
    message: "Set TELEGRAM_BOT_TOKEN in .env before starting the bot.",
  });
  process.exit(1);
}

const bot = new Bot(token);
const pendingQuotes = new Map<string, TradeQuote>();

function mainMenuKeyboard() {
  return new InlineKeyboard()
    .text("Scanner", "scan:solana")
    .text("Top Picks", "scan:top")
    .row()
    .text("Analisis BONK", "analyze:BONK")
    .text("Analisis WIF", "analyze:WIF")
    .row()
    .text("Watchlist", "watchlist:open")
    .text("Alerts", "alert:open")
    .row()
    .text("Positions", "positions:open")
    .text("Settings", "settings:open");
}

function persistentKeyboard() {
  return new Keyboard()
    .text("Scanner Meme Coin")
    .text("Top Picks")
    .row()
    .text("Watchlist")
    .text("Positions")
    .row()
    .text("Alerts")
    .text("Settings")
    .resized()
    .persistent();
}

function modeKeyboard(query: string) {
  return new InlineKeyboard()
    .text("Scalp", `mode:${query}:scalp`)
    .text("Momentum", `mode:${query}:momentum`)
    .row()
    .text("Snipe", `mode:${query}:snipe`)
    .text("Conservative", `mode:${query}:conservative`)
    .row()
    .text("Refresh", `mode:${query}:momentum`)
    .text("Menu", "menu:main");
}

function tradeConfirmKeyboard(quoteId: string) {
  return new InlineKeyboard().text("Confirm", `trade_confirm:${quoteId}`).text("Cancel", `trade_cancel:${quoteId}`).row().text("Menu", "menu:main");
}

async function sendScanner(chatId: number) {
  const candidates = await memeMarketDataRouter.scanMemeCoins({
    chainId: "solana",
    limit: 8,
    minLiquidityUsd: 10000,
    minVolumeH24: 10000,
    minScore: 45,
    maxAgeHours: 168,
  });

  await bot.api.sendMessage(chatId, formatScannerResults(candidates), {
    parse_mode: "HTML",
    link_preview_options: { is_disabled: true },
    reply_markup: mainMenuKeyboard(),
  });
}

async function sendAnalysis(chatId: number, query: string, mode: TradingMode) {
  const candidate = await memeMarketDataRouter.getMemeCandidate(query, "solana");
  const signal = generateMemeSignalAnalysis({ query, mode, chainId: candidate.token.chainId }, candidate);
  let message = formatMemeSignalCard(signal);

  if (isGithubModelsConfigured()) {
    try {
      const review = await generateAiReview({ prompt: buildAiSignalReviewPrompt(signal) });
      message = `${message}\n\n<b>AI Risk Review</b>\n${review.content}`;
    } catch (error) {
      const safeMessage = error instanceof Error ? error.message : "Unknown AI review error";
      logger.error("ai_review_failed", { token: candidate.token.symbol, safeMessage });
    }
  }

  await bot.api.sendMessage(chatId, message, {
    parse_mode: "HTML",
    link_preview_options: { is_disabled: true },
    reply_markup: modeKeyboard(query),
  });
}

async function prepareTrade(chatId: number, userId: string, side: TradeSide, args: string) {
  const [tokenAddress, amountRaw, modeRaw] = args.trim().split(/\s+/);
  const amount = Number(amountRaw);
  const requestedMode = modeRaw === "live" ? "live" : "paper";

  const parsed = tradeRequestSchema.safeParse({
    userId,
    chainId: "solana",
    tokenAddress,
    side,
    amount,
    slippageBps: Number(process.env.DEFAULT_SLIPPAGE_BPS ?? 300),
    mode: requestedMode,
  });

  if (!parsed.success) {
    await bot.api.sendMessage(
      chatId,
      [
        "<b>Format transaksi tidak valid</b>",
        "",
        "Buy:",
        "<code>/buy TOKEN_ADDRESS 0.05</code>",
        "",
        "Sell:",
        "<code>/sell TOKEN_ADDRESS 25</code>",
        "",
        "Tambahkan <code>live</code> di akhir hanya jika executor live sudah dikonfigurasi:",
        "<code>/buy TOKEN_ADDRESS 0.05 live</code>",
      ].join("\n"),
      { parse_mode: "HTML", reply_markup: mainMenuKeyboard() },
    );
    return;
  }

  const quote = await tradeExecutor.buildQuote(parsed.data);
  pendingQuotes.set(quote.id, quote);
  await bot.api.sendMessage(chatId, formatTradeQuote(quote), {
    parse_mode: "HTML",
    reply_markup: tradeConfirmKeyboard(quote.id),
  });
}

await bot.api.setMyCommands([
  { command: "start", description: "Open meme coin trading menu" },
  { command: "scan", description: "Scan meme coin candidates. Example: /scan solana" },
  { command: "top", description: "Show top meme coin candidates" },
  { command: "analyze", description: "Analyze token. Example: /analyze BONK" },
  { command: "buy", description: "Prepare buy. Example: /buy TOKEN_ADDRESS 0.05" },
  { command: "sell", description: "Prepare sell. Example: /sell TOKEN_ADDRESS 25" },
  { command: "alert", description: "Create alert help" },
  { command: "watchlist", description: "Open watchlist" },
  { command: "positions", description: "Open paper/live position summary" },
  { command: "settings", description: "Trading mode and risk settings" },
  { command: "help", description: "How to use the bot" },
]);

bot.command("start", async (ctx) => {
  await ctx.reply(formatMainMenuCard(), {
    parse_mode: "HTML",
    reply_markup: persistentKeyboard(),
  });
  await ctx.reply("Pilih menu atau kirim alamat token/symbol meme coin langsung.", { reply_markup: mainMenuKeyboard() });
});

bot.command(["scan", "top"], async (ctx) => {
  await sendScanner(ctx.chat.id);
});

bot.command("analyze", async (ctx) => {
  const query = ctx.match?.trim() || "BONK";
  await sendAnalysis(ctx.chat.id, query, "momentum");
});

bot.command("buy", async (ctx) => {
  await prepareTrade(ctx.chat.id, String(ctx.from?.id ?? ctx.chat.id), "BUY", ctx.match ?? "");
});

bot.command("sell", async (ctx) => {
  await prepareTrade(ctx.chat.id, String(ctx.from?.id ?? ctx.chat.id), "SELL", ctx.match ?? "");
});

bot.command("alert", async (ctx) => {
  await ctx.reply(formatAlertHelp(), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
});

bot.command("watchlist", async (ctx) => {
  await ctx.reply(
    [
      "<b>Watchlist</b>",
      "",
      "Default watchlist:",
      "- BONK",
      "- WIF",
      "- PEPE",
      "",
      "Kirim <code>/analyze SYMBOL</code> atau alamat token untuk analisis realtime.",
    ].join("\n"),
    { parse_mode: "HTML", reply_markup: mainMenuKeyboard() },
  );
});

bot.command("positions", async (ctx) => {
  await ctx.reply(
    [
      "<b>Positions</b>",
      "",
      "Paper/live position storage siap di schema database.",
      "MVP Telegram saat ini menampilkan hasil transaksi dari flow confirm trade.",
    ].join("\n"),
    { parse_mode: "HTML", reply_markup: mainMenuKeyboard() },
  );
});

bot.command("settings", async (ctx) => {
  await ctx.reply(
    [
      "<b>Settings</b>",
      "",
      `Trading mode runtime: <code>${process.env.TRADING_MODE === "live" ? "live" : "paper"}</code>`,
      `Default slippage: <code>${Number(process.env.DEFAULT_SLIPPAGE_BPS ?? 300) / 100}%</code>`,
      "",
      "Modes: scalp, momentum, snipe, conservative.",
      "Live execution butuh TRADE_EXECUTOR_URL dan TRADE_EXECUTOR_TOKEN.",
    ].join("\n"),
    { parse_mode: "HTML", reply_markup: mainMenuKeyboard() },
  );
});

bot.command("help", async (ctx) => {
  await ctx.reply(formatMainMenuCard(), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
});

bot.callbackQuery("menu:main", async (ctx) => {
  await ctx.answerCallbackQuery();
  await ctx.editMessageText(formatMainMenuCard(), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
});

bot.callbackQuery(/^scan:/, async (ctx) => {
  await ctx.answerCallbackQuery({ text: "Scanning..." });
  await sendScanner(ctx.chat?.id ?? ctx.from.id);
});

bot.callbackQuery(/^analyze:(.+)$/, async (ctx) => {
  await ctx.answerCallbackQuery({ text: "Analyzing..." });
  await sendAnalysis(ctx.chat?.id ?? ctx.from.id, ctx.match[1], "momentum");
});

bot.callbackQuery(/^mode:([^:]+):([^:]+)$/, async (ctx) => {
  await ctx.answerCallbackQuery({ text: "Refreshing analysis..." });
  await sendAnalysis(ctx.chat?.id ?? ctx.from.id, ctx.match[1], ctx.match[2] as TradingMode);
});

bot.callbackQuery(/^trade_confirm:(.+)$/, async (ctx) => {
  await ctx.answerCallbackQuery({ text: "Executing..." });
  const quote = pendingQuotes.get(ctx.match[1]);

  if (!quote) {
    await ctx.editMessageText("Quote sudah expired. Buat preview transaksi baru.", { reply_markup: mainMenuKeyboard() });
    return;
  }

  const result = await tradeExecutor.executeTrade(quote);
  pendingQuotes.delete(quote.id);
  await ctx.editMessageText(formatTradeResult(result), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
});

bot.callbackQuery(/^trade_cancel:(.+)$/, async (ctx) => {
  await ctx.answerCallbackQuery({ text: "Canceled" });
  pendingQuotes.delete(ctx.match[1]);
  await ctx.editMessageText("Trade dibatalkan.", { reply_markup: mainMenuKeyboard() });
});

bot.callbackQuery("alert:open", async (ctx) => {
  await ctx.answerCallbackQuery();
  await ctx.editMessageText(formatAlertHelp(), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
});

bot.callbackQuery(/^watchlist:|^positions:|^settings:/, async (ctx) => {
  await ctx.answerCallbackQuery();
  await ctx.editMessageText("Gunakan command /watchlist, /positions, atau /settings untuk detail terbaru.", { reply_markup: mainMenuKeyboard() });
});

bot.hears(["Scanner Meme Coin", "Top Picks"], async (ctx) => {
  await sendScanner(ctx.chat.id);
});

bot.hears(["Watchlist", "Positions", "Alerts", "Settings"], async (ctx) => {
  const text = ctx.message?.text;
  if (text === "Watchlist") return ctx.reply("Default watchlist: BONK, WIF, PEPE.", { reply_markup: mainMenuKeyboard() });
  if (text === "Positions") return ctx.reply("Position summary siap untuk paper/live trade result.", { reply_markup: mainMenuKeyboard() });
  if (text === "Alerts") return ctx.reply(formatAlertHelp(), { parse_mode: "HTML", reply_markup: mainMenuKeyboard() });
  return ctx.reply(`Trading mode runtime: ${process.env.TRADING_MODE === "live" ? "live" : "paper"}`, { reply_markup: mainMenuKeyboard() });
});

bot.on("message:text", async (ctx) => {
  const text = ctx.message.text.trim();
  const query = inferTokenQueryFromText(text);
  const mode = inferModeFromText(text);

  try {
    await sendAnalysis(ctx.chat.id, query, mode);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown meme analysis error";
    logger.error("free_text_meme_analysis_failed", { message, query, mode });
    await ctx.reply("Analisis token belum bisa diproses sekarang. Coba /scan atau /analyze BONK.", { reply_markup: mainMenuKeyboard() });
  }
});

bot.catch((error) => {
  logger.error("telegram_bot_error", { error: error.error });
});

bot.start({
  onStart: async (botInfo) => {
    logger.info("telegram_bot_started", { username: botInfo.username, scope: "meme_coin_only" });
    await bot.api.setChatMenuButton({ menu_button: { type: "commands" } });
  },
});