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" } });
},
});