Bot & Automation

Market AI

/root/hermes-projects/Market AI

apps/api/src/server.ts text
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import Fastify from "fastify";
import {
  BOT_NAME,
  generateMemeSignalAnalysis,
  logger,
  memeSignalRequestSchema,
  providerStatusSnapshot,
  scannerCriteriaSchema,
  tradeRequestSchema,
} from "@market-ai/core";
import { buildAiSignalReviewPrompt } from "@market-ai/prompts";
import { generateAiReview, isGithubModelsConfigured, memeMarketDataRouter, tradeExecutor } from "@market-ai/provider-adapters";

const port = Number(process.env.PORT ?? 8080);
const host = process.env.HOST ?? "0.0.0.0";

const app = Fastify({
  logger: false,
  genReqId: () => crypto.randomUUID(),
});

await app.register(helmet);
await app.register(cors, {
  origin: process.env.APP_BASE_URL ? [process.env.APP_BASE_URL] : true,
});

app.get("/health", async () => ({
  status: "ok",
  service: "meme-market-ai-api",
  bot: BOT_NAME,
  tradingMode: process.env.TRADING_MODE === "live" ? "live" : "paper",
  env: process.env.NODE_ENV ?? "development",
  timestamp: new Date().toISOString(),
}));

app.get("/meme/scan", async (request, reply) => {
  const parsed = scannerCriteriaSchema.safeParse(request.query);

  if (!parsed.success) {
    return reply.status(400).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Scanner query is not valid.",
        safeDetail: parsed.error.issues.map((issue) => issue.message).join(", "),
      },
    });
  }

  const candidates = await memeMarketDataRouter.scanMemeCoins(parsed.data);
  return { candidates };
});

app.get("/meme/search", async (request, reply) => {
  const query = request.query as { q?: string; chainId?: string };

  if (!query.q?.trim()) {
    return reply.status(400).send({
      error: { code: "VALIDATION_ERROR", message: "Query parameter q is required." },
    });
  }

  const candidates = await memeMarketDataRouter.searchMemeCoins(query.q, {
    chainId: query.chainId ?? "solana",
    minLiquidityUsd: 0,
    minVolumeH24: 0,
    minScore: 0,
    maxAgeHours: 3650,
    limit: 10,
  });

  return { candidates };
});

app.post("/meme/analyze", async (request, reply) => {
  const parsed = memeSignalRequestSchema.safeParse(request.body);

  if (!parsed.success) {
    return reply.status(400).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Analysis request is not valid.",
        safeDetail: parsed.error.issues.map((issue) => issue.message).join(", "),
      },
    });
  }

  const candidate = await memeMarketDataRouter.getMemeCandidate(parsed.data.query, parsed.data.chainId ?? "solana");
  const signal = generateMemeSignalAnalysis(parsed.data, candidate);
  const aiReview = isGithubModelsConfigured()
    ? await generateAiReview({ prompt: buildAiSignalReviewPrompt(signal) }).catch((error: unknown) => {
        const safeMessage = error instanceof Error ? error.message : "AI review unavailable.";
        logger.error("ai_review_failed", { requestId: request.id, token: candidate.token.symbol, safeMessage });

        return {
          provider: "github_models" as const,
          model: process.env.GITHUB_MODELS_MODEL,
          content: "AI review belum tersedia. Gunakan signal teknikal hanya sebagai analisis edukatif dengan risk management ketat.",
        };
      })
    : undefined;

  return { signal, aiReview };
});

app.post("/trade/quote", async (request, reply) => {
  const parsed = tradeRequestSchema.safeParse(request.body);

  if (!parsed.success) {
    return reply.status(400).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Trade request is not valid.",
        safeDetail: parsed.error.issues.map((issue) => issue.message).join(", "),
      },
    });
  }

  return { quote: await tradeExecutor.buildQuote(parsed.data) };
});

app.post("/trade/execute", async (request, reply) => {
  const parsed = tradeRequestSchema.safeParse(request.body);

  if (!parsed.success) {
    return reply.status(400).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Trade request is not valid.",
        safeDetail: parsed.error.issues.map((issue) => issue.message).join(", "),
      },
    });
  }

  const quote = await tradeExecutor.buildQuote(parsed.data);
  const result = await tradeExecutor.executeTrade(quote);
  return { result };
});

app.get("/ai/status", async () => ({
  provider: process.env.AI_PROVIDER ?? "not_configured",
  githubModelsConfigured: isGithubModelsConfigured(),
  model: process.env.GITHUB_MODELS_MODEL ?? null,
}));

app.get("/ai/smoke", async () => {
  const result = await generateAiReview({
    prompt: "Reply with OK only. This is a neutral API connectivity test.",
    system: "You are a connectivity test assistant.",
  });

  return {
    ok: result.content.toLowerCase().includes("ok"),
    provider: result.provider,
    model: result.model,
    sample: result.content.slice(0, 80),
  };
});

app.get("/providers/status", async () => ({
  providers: providerStatusSnapshot(),
}));

app.get("/watchlist/default", async () => ({
  items: ["BONK", "WIF", "PEPE"],
}));

app.get("/alerts/schema", async () => ({
  supportedConditions: ["price_above", "price_below", "volume_spike", "score_above", "liquidity_below"],
  example: { tokenAddress: "TOKEN_ADDRESS", condition: "score_above", target: 75 },
}));

app.setErrorHandler((error: Error & { code?: string }, request, reply) => {
  logger.error("api_request_failed", {
    requestId: request.id,
    errorCode: error.code,
    message: error.message,
  });

  return reply.status(500).send({
    error: {
      code: "INTERNAL_ERROR",
      message: "Something went wrong.",
      safeDetail: "Coba lagi nanti. Detail internal tidak ditampilkan untuk keamanan.",
    },
  });
});

try {
  await app.listen({ port, host });
  logger.info("api_started", { port, host });
} catch (error) {
  const message = error instanceof Error ? error.message : "Unknown startup error";
  logger.error("api_start_failed", { message });
  process.exit(1);
}