File size: 29,879 Bytes
115a637 de83fbe 115a637 f57b232 115a637 a7d295c 115a637 de83fbe 115a637 de83fbe 115a637 a7d295c de83fbe a7d295c de83fbe a7d295c 84005c5 6bb5dc5 84005c5 de83fbe bf3ef55 de83fbe 7bf9c46 0b60b70 de83fbe 84005c5 de83fbe bf3ef55 0b60b70 7bf9c46 84005c5 115a637 a7d295c 115a637 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 | """
modules/bot_api_server.py — direct HTTP API the AWS bot exposes (v18.0).
Replaces the old push-heartbeat/poll-relay pair (bot.py's
_status_heartbeat_loop/_command_poll_loop/_handle_query_command, all
deleted — see bot.py's v18.0 changelog) that used to bounce every dashboard
interaction through a Cloudflare Worker's /status, /command and
/command-result routes. That Worker is retired; this bot now runs
standalone on AWS (Frankfurt) and serves its own status/control API
directly, which the Hugging Face dashboard (app.py, DASHBOARD_ONLY mode)
calls over the internet using BOT_API_URL + BOT_API_TOKEN instead of
ORACLE_URL + RELAY_AUTH_TOKEN.
Endpoints:
GET /status — live-computed JSON status snapshot (no auth — same
shape the old /status push payload had, plus a new
jupiter_tier field). Computed fresh on every request,
never cached.
POST /command — Bearer-token gated (BOT_API_TOKEN). Body:
{"action": str, "params": dict|null}. Routes `action`
to the same handler functions the old poll loop called,
but SYNCHRONOUSLY — one HTTP request/response, no more
request_id + second poll for the result. Returns
{"ok": true, "result": "..."} or {"ok": false, "error":
"..."}.
Run as an asyncio background task from bot.py (self._spawn_background),
started unconditionally — with no BOT_API_TOKEN configured, /command
always 503s (logged once) but /status still works, since it carries no
secrets.
"""
from __future__ import annotations
import asyncio
import hmac
import logging
import os
import time
from typing import Any
logger = logging.getLogger(__name__)
_DEFAULT_HOST = "0.0.0.0"
_DEFAULT_PORT = 8081
# Fire-and-forget actions — reuse the exact same CommandHandlers methods
# Telegram's /ghost_mode, /mint_mode and /hunt already call, and report to
# the same Telegram chat, so a dashboard-triggered action leaves the same
# audit trail a Telegram command would. Each takes (chat_id) and sends its
# own Telegram message; the HTTP response below just confirms it ran.
_FIRE_AND_FORGET_ACTIONS = ("toggle_ghost", "toggle_mint", "hunt")
# Data-query actions — rendered via the shared modules/dashboard_panels.py
# renderers (same ones app.py's non-DASHBOARD_ONLY path and the /speedtest
# Telegram command use) and returned directly in the HTTP response body.
_QUERY_ACTIONS = (
"wallet_status", "gas_health", "price_check", "scanner_tokens",
"solana_prices", "solana_routes", "arbitrage_detail", "audit_log",
"speedtest",
)
def _bot_api_token() -> str:
return os.getenv("BOT_API_TOKEN", "").strip()
def _note_auth(request: Any, ok: bool) -> None:
"""Report an authentication outcome to the HTTP guard (v18.2).
Best-effort by design: if the guard is unavailable or raises, the auth
decision itself has already been made and must stand. This can only
ever add a future reason to refuse, never grant access.
"""
try:
from modules.http_guard import client_key, get_guard
guard = get_guard()
key = request.get("client_key") or client_key(request)
if ok:
guard.note_auth_success(key)
else:
guard.note_auth_failure(key)
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] could not record auth outcome: %s", exc)
def _bot_api_host() -> str:
return os.getenv("BOT_API_HOST", _DEFAULT_HOST).strip() or _DEFAULT_HOST
def _bot_api_port() -> int:
try:
return int(os.getenv("BOT_API_PORT", str(_DEFAULT_PORT)).strip())
except ValueError:
return _DEFAULT_PORT
def _auto_hunt_enabled() -> bool:
"""Same semantics as bot.py's own _auto_hunt_enabled() — duplicated as
a tiny env-var read rather than imported, to avoid a circular import
(bot.py imports this module at startup, before its own module-level
code has finished running)."""
return os.environ.get("AUTO_HUNT_ENABLED", "false").strip().lower() in (
"1", "true", "yes", "on",
)
async def _build_status_snapshot(bot: Any) -> dict[str, Any]:
"""Same fields the old _status_heartbeat_loop's push payload had, plus
jupiter_tier (new). Computed fresh every call — no caching."""
from constants import jupiter_tier_status
h = getattr(bot, "_handlers", None)
payout = getattr(bot, "_payout", None)
ledger = await payout.get_status() if payout is not None else None
solana = getattr(bot, "_solana", None)
_is_paid, tier_name = jupiter_tier_status()
return {
"uptime_s": int(time.time() - getattr(bot, "start_time", time.time())),
"telegram_connected": bool(getattr(getattr(bot, "_tg", None), "reachable", False)),
"ghost_mode": bool(getattr(h, "_ghost_mode", False)) if h else False,
"mint_mode": bool(getattr(h, "_mint_mode", False)) if h else False,
"auto_hunt": _auto_hunt_enabled(),
"min_profit_arb": h.effective_min_profit if h else getattr(bot.config, "min_profit_usd", None),
# FIX — this used to independently re-read SOLANA_MIN_PROFIT_USD
# with its own hardcoded default (2.0, different from
# solana_arb.py's own default), the same class of banner-vs-
# enforced-value drift already fixed once for mint mode (see
# modules/command_handlers.py's v17.20 FIX). Now reads the LIVE
# SolanaArbEngine instance's own .min_profit attribute so the
# displayed value can never drift from what's actually enforced.
"min_profit_sol": solana.min_profit if solana is not None else None,
"total_profit_usd": (ledger or {}).get("net_profit", 0.0),
"scans_total": (ledger or {}).get("scan_count", 0),
"scans_buy": (ledger or {}).get("buy_count", 0),
"dry_run": getattr(bot.config, "dry_run", None),
"jupiter_tier": tier_name,
# v18.1 — operational fields for the public page. Audited as
# non-secret before being added here, because this endpoint is
# unauthenticated and fronted by bot.elghaly.dev: scan age is a
# timestamp delta, route counts are integers, and the governor's
# rate is a number about our own pacing. No URL, no address, no
# key, no config value that would help an attacker. The RPC URL in
# particular stays OUT — SOLANA_RPC_PRIMARY embeds a provider API
# key in its path on this deployment.
"solana": ({
"last_scan_age_secs": (
round(time.time() - solana.last_scan_ts, 1) if solana.last_scan_ts else None
),
"scan_count": solana.scan_count,
"signal_count": solana.signal_count,
"routes": len(solana.routes),
"paused": bool(getattr(solana, "paused", False)),
} if solana is not None else None),
}
async def _dispatch_action(bot: Any, action: str, params: dict) -> str:
"""Compute one action's result. Fire-and-forget actions return a short
confirmation string (the real audit trail is the Telegram message they
send); query actions return the rendered markdown directly. Any
failure is rendered as an error string rather than raised, so a bad
request can't 500 the whole endpoint for an unrelated reason."""
from modules import dashboard_panels as panels
handlers = getattr(bot, "_handlers", None)
chat_id = getattr(bot.config, "telegram_chat_id", None)
if action in _FIRE_AND_FORGET_ACTIONS:
if handlers is None:
return "❌ Bot not ready yet — handlers not constructed."
if action == "toggle_ghost":
await handlers._cmd_ghost_mode(chat_id)
elif action == "toggle_mint":
await handlers._cmd_mint_mode(chat_id)
elif action == "hunt":
await handlers._cmd_hunt(chat_id)
return f"✅ {action} executed — see Telegram for confirmation."
if action in _QUERY_ACTIONS:
if action == "wallet_status":
return await panels.render_wallet_status(bot)
if action == "gas_health":
return await panels.render_gas_health(bot)
if action == "price_check":
return await panels.render_price_check(bot, params.get("assets", ""))
if action == "scanner_tokens":
return await panels.render_scanner_tokens(bot, _auto_hunt_enabled())
if action == "solana_prices":
return await panels.render_solana_prices(bot)
if action == "solana_routes":
return await panels.render_solana_routes(bot)
if action == "arbitrage_detail":
return await panels.render_arbitrage_detail(bot)
if action == "audit_log":
return await panels.render_audit_log(bot)
if action == "speedtest":
return await panels.render_speedtest(bot)
return f"❌ Unknown action: {action}"
def _read_recent_log_lines(limit: int = 200) -> str:
"""v18.1 — tail the bot's own rotating log file.
Operator need: the Hugging Face dashboard runs in DASHBOARD_ONLY mode and
therefore has no bot of its own, so the only logs it could ever show were
its own (empty) process — i.e. never the AWS bot actually trading. This
serves the real thing.
Reads modules/logger.py's LOG_FILE rather than shelling out to
journalctl: no subprocess, no sudo, and it works regardless of which
user the service runs as or whether journald is even in use. Bounded to
the tail so a multi-megabyte log can't be pulled into memory or pushed
down the wire in one response."""
path = os.getenv("LOG_FILE", "").strip()
if not path:
return ("LOG_FILE is not set, so the bot is not writing a log file — "
"only stdout/journald. Set LOG_FILE=bot.log in .env and "
"restart to enable this view.")
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
# Seek to a bounded window from EOF instead of reading the whole
# file; 200 lines is far under 256 KiB in this log's format.
try:
fh.seek(0, os.SEEK_END)
fh.seek(max(0, fh.tell() - 256_000))
fh.readline() # discard the partial line the seek landed in
except OSError:
pass
lines = fh.readlines()
tail = [ln.rstrip("\n") for ln in lines[-limit:]]
return "\n".join(tail) if tail else "(log file is empty)"
except FileNotFoundError:
return f"Log file not found at {path} — the bot may not have written to it yet."
except Exception as exc:
return f"Could not read log file: {exc}"
async def run_bot_api_server(bot: Any) -> None:
"""Serve GET /status and POST /command until the bot stops. Spawned as
a background task from bot.py's start() — see this module's own
docstring for the full endpoint contract."""
try:
from aiohttp import web
except ImportError:
logger.error(
"[BotAPI] aiohttp not installed — the bot's HTTP status/"
"command API will not run. pip install aiohttp."
)
return
token = _bot_api_token()
if not token:
logger.warning(
"[BotAPI] BOT_API_TOKEN is not set — /status will still work "
"(no secrets in it), but /command will reject every request. "
"Set BOT_API_TOKEN to let the Hugging Face dashboard control "
"this bot remotely."
)
async def handle_status(request: "web.Request") -> "web.Response":
try:
snapshot = await _build_status_snapshot(bot)
return web.json_response(snapshot)
except Exception as exc:
logger.warning("[BotAPI] /status failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
async def handle_command(request: "web.Request") -> "web.Response":
configured_token = _bot_api_token()
if not configured_token:
return web.json_response(
{"ok": False, "error": "BOT_API_TOKEN not configured on this bot"},
status=503,
)
auth = request.headers.get("Authorization", "")
if not hmac.compare_digest(auth, f"Bearer {configured_token}"):
# v18.2 — feed the failure to modules/http_guard.py, which locks
# the client out after a handful of attempts. Without this, the
# only thing bounding a brute-force run against a fund-moving
# endpoint is how fast the attacker's network is.
_note_auth(request, ok=False)
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
_note_auth(request, ok=True)
try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid JSON body"}, status=400)
action = (body or {}).get("action")
params = (body or {}).get("params") or {}
if not action:
return web.json_response({"ok": False, "error": "missing action"}, status=400)
try:
logger.info("[BotAPI] executing remote command: %s", action)
result = await _dispatch_action(bot, action, params)
return web.json_response({"ok": True, "result": result})
except Exception as exc:
logger.warning("[BotAPI] action %s failed: %s", action, exc)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
async def handle_logs(request: "web.Request") -> "web.Response":
"""Bearer-gated like /command, not open like /status: log lines are
operational detail (wallet addresses, tx signatures, route sizes),
which /status deliberately does not expose."""
configured_token = _bot_api_token()
if not configured_token:
return web.json_response(
{"ok": False, "error": "BOT_API_TOKEN not configured on this bot"},
status=503,
)
if not hmac.compare_digest(
request.headers.get("Authorization", ""), f"Bearer {configured_token}"
):
_note_auth(request, ok=False)
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
_note_auth(request, ok=True)
try:
limit = min(int(request.query.get("limit", 200)), 1000)
except ValueError:
limit = 200
text = await asyncio.to_thread(_read_recent_log_lines, limit)
return web.json_response({"ok": True, "logs": text})
# ── Public read-only surface for bot.elghaly.dev (v18.1, 2026-07-29) ──
#
# Operator: "a public read-only stats page vs an authenticated control
# page — you already have this instinct (the /logs 401 earlier was
# correct behavior, keep that pattern everywhere)."
#
# So these four routes are UNAUTHENTICATED BY DESIGN and carry nothing
# that needs protecting: the page itself is a static string, and both
# JSON endpoints read only the trade journal (route names, sizes,
# spreads, on-chain signatures — all of it either already public on
# Solana or meaningless to an attacker). No wallet address, no RPC URL,
# no key material, no config, and above all no way to ACT. /command
# keeps its Bearer token; /logs keeps its 401.
#
# That line matters more than it looks: this page is meant to be public
# at bot.elghaly.dev, and a public page that can move funds is a public
# page that will move funds.
async def handle_index(request: "web.Request") -> "web.Response":
from modules.web_dashboard import INDEX_HTML
return web.Response(text=INDEX_HTML, content_type="text/html")
async def handle_api_pnl(request: "web.Request") -> "web.Response":
from modules.ledger_report import pnl
# to_thread: these read (tailed) CSVs off disk, and the event loop
# this shares with the scan loop must never block on I/O.
return web.json_response(await asyncio.to_thread(pnl))
async def handle_api_receipts(request: "web.Request") -> "web.Response":
from modules.ledger_report import receipts
try:
# max(1, ...) as well as min(..., 500): Python slices backwards
# on a negative bound, so `?limit=-1` would have returned
# nearly every row and defeated the cap entirely. This endpoint
# is deliberately unauthenticated, so its parameters have to be
# robust against someone who is not being polite.
limit = max(1, min(int(request.query.get("limit", 100)), 500))
except (TypeError, ValueError):
limit = 100
rows = await asyncio.to_thread(receipts, limit)
return web.json_response({
"receipts": rows,
"verifiable": True,
"note": (
"Each signature is independently checkable on Solana. This "
"endpoint returns ONLY trades that produced a real on-chain "
"signature — never estimates, simulations or projections."
),
})
async def handle_receipts_csv(request: "web.Request") -> "web.Response":
from modules.ledger_report import receipts_csv
text = await asyncio.to_thread(receipts_csv)
return web.Response(
text=text, content_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="receipts.csv"'},
)
async def handle_api_routes(request: "web.Request") -> "web.Response":
"""v18.2 — route-pruning transparency, public.
Operator: "expose why a route got pruned (e.g. '0 signals in 500
evals over 6h') so you can tell a dead route from a temporarily
quiet one."
Serves route_scorer.public_table(), which is the internal table
with every dollar figure removed — see that method for the audit.
Route names and basis points are safe to publish; loan sizes are
position information and are not.
"""
from modules.route_scorer import get_scorer
scorer = get_scorer()
try:
table = await asyncio.to_thread(scorer.public_table)
status = scorer.status()
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] /api/routes failed: %s", exc)
return web.json_response({"routes": [], "error": "unavailable"})
return web.json_response({
"routes": table,
"pruning_enabled": status.get("enabled"),
"routes_pruned": status.get("routes_pruned"),
"min_active_routes": status.get("min_active_routes"),
"dead_bps": status.get("dead_bps"),
"floor_bound": status.get("floor_bound"),
"probe_every_cycles": status.get("probe_every_cycles"),
"note": (
"A pruned route is asleep on a timer, not deleted — it is "
"re-probed on a fixed cycle and reinstated the moment it "
"produces. `explain` states the evidence and the observation "
"window for every route, pruned or not."
),
})
async def handle_api_engine(request: "web.Request") -> "web.Response":
"""v18.3 — the engine's own instruments, in one payload.
Operator: "add some more data" — and, in the same breath, "make
secure just me". Those two travel together: this endpoint carries
edge-decay measurements, Jito tip state, the trading circuit
breaker and the drawdown watcher, which is meaningfully more than
the earlier public payloads exposed.
Each block is still built by hand rather than dumping the modules'
full status() dicts, because those are written for /doctor — an
authenticated, operator-only view — and would carry the signing
wallet, file paths and threshold config out onto a web page. The
rule from the public endpoints has not been relaxed just because
there is a password in front of it now: a password is one
credential, and a page that leaks a wallet address to whoever
holds it is still a page that leaks a wallet address.
"""
out: dict[str, Any] = {}
try:
from modules.decay_tracker import get_tracker
st = get_tracker().status()
out["decay"] = {
"recording": st.get("recording"),
"samples": st.get("samples"),
"min_samples": st.get("min_samples"),
"usd_per_sec": st.get("decay_usd_per_sec"),
"pipeline_median_secs": st.get("pipeline_median_secs"),
"pipeline_p90_secs": st.get("pipeline_p90_secs"),
"implied_adder_usd": st.get("implied_adder_usd"),
"gate_enabled": st.get("gate_enabled"),
}
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] decay block failed: %s", exc)
try:
from modules.trading_guard import get_guard
st = get_guard().status()
out["guard"] = {
"state": st.get("state"),
"trips": st.get("trips"),
"attempts": st.get("attempts"),
"successes": st.get("successes"),
"blocked": st.get("blocked"),
"reason": (st.get("trip_reason") or "")[:200],
}
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] guard block failed: %s", exc)
try:
from modules.jito_tip_engine import get_tip_engine
st = get_tip_engine().status()
out["jito"] = {
"gamma": st.get("gamma"),
"bundles_sent": st.get("bundles_submitted"),
"bundles_landed": st.get("bundles_landed"),
"landing_rate": st.get("landing_rate"),
"priced_out": st.get("priced_out"),
"tips_paid_sol": st.get("tip_sol_paid"),
# COUNT, not the list. The block-engine URLs are not secret,
# but they are infrastructure detail with no reason to be on
# a web page — and "4 engines" is the whole of what a reader
# needs from that field.
"engines": len(st.get("block_engines") or []),
}
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] jito block failed: %s", exc)
try:
from modules.drawdown_alert import get_watcher
st = get_watcher().status()
out["drawdown"] = {
"enabled": st.get("enabled"),
"halt_on_breach": st.get("halt_on_breach"),
"thresholds": st.get("thresholds"),
"latches": st.get("latches"),
}
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] drawdown block failed: %s", exc)
try:
from modules.http_guard import get_guard as get_http_guard
st = get_http_guard().status()
out["http"] = {
"served": st.get("served"),
"throttled_429": st.get("throttled_429"),
"shed_503": st.get("shed_503"),
"auth_lockouts": st.get("auth_lockouts"),
"locked_clients": st.get("locked_clients"),
}
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] http block failed: %s", exc)
return web.json_response(out)
async def handle_api_nearmiss(request: "web.Request") -> "web.Response":
"""v18.2 — failure-mode logging, public.
Operator: "logging near-misses — signals that crossed your
detection threshold but reverted or got beaten to the block —
since that tells you if you're losing races, not just whether
you're finding opportunities."
Public because it is the honest half of the story. A page showing
only executed trades reads as either "no losses" or "no activity"
with no way to tell which; publishing the near-misses alongside is
what makes the receipts section a claim about completeness rather
than a selection.
"""
from modules.near_miss import get_log
near = get_log()
try:
limit = max(1, min(int(request.query.get("limit", 25)), 200))
except (TypeError, ValueError):
limit = 25
try:
summary = await asyncio.to_thread(near.summary)
recent = await asyncio.to_thread(near.recent, limit)
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] /api/nearmiss failed: %s", exc)
return web.json_response({"summary": {}, "recent": [], "error": "unavailable"})
# log_path is a server filesystem path — useful in /doctor, not on
# a public endpoint, so it is dropped here rather than never
# collected.
summary.pop("log_path", None)
return web.json_response({"summary": summary, "recent": recent})
async def handle_api_observatory(request: "web.Request") -> "web.Response":
"""v18.5 — the recorded series, not another snapshot.
Operator's request, in their own framing: a public view of Solana
tip/MEV conditions that runs continuously, because other bot
builders want that data and producing it risks no capital.
Everything here is either published by Jito already or measured
from public quotes, so there is nothing to withhold — but the
endpoint-name labels from rpc_latency are deliberately env-var
names ("SOLANA_RPC_PRIMARY"), never URLs, because the primary RPC
embeds a provider API key in its path on this deployment. See
scripts/observe.py, which records them that way for this reason.
"""
from modules.observatory import viability
try:
data = await asyncio.to_thread(viability)
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] /api/observatory failed: %s", exc)
return web.json_response({"rows": 0, "error": "unavailable"})
return web.json_response(data)
async def handle_api_lighthouse(request: "web.Request") -> "web.Response":
"""v18.4 — the verdict that ranks the other panels, public.
Operator: "improve his web its so ugly."
The page opened with a table, which meant a reader — including the
operator on a phone — had to assemble the answer from five sections
before knowing whether anything was wrong. This serves the same
five-gate judgement /lighthouse gives in Telegram, so the page can
lead with the conclusion and keep the evidence underneath it.
`public=True` drops the `action` field. The findings carry shell
commands naming scripts and env vars, and operator instructions on
a web page are exactly what this file's payload rule exists to
prevent. The diagnosis stays — knowing that the pipeline is slow is
not a credential — only the runbook line is withheld.
"""
from modules.lighthouse_report import lighthouse
try:
data = await asyncio.to_thread(lighthouse, bot, True)
except Exception as exc: # noqa: BLE001
logger.debug("[BotAPI] /api/lighthouse failed: %s", exc)
return web.json_response({"stages": [], "verdict": None,
"error": "unavailable"})
return web.json_response(data)
# v18.2 — modules/http_guard.py. The server now shares an event loop
# with the scan loop AND faces the public internet, which makes an
# enthusiastic scraper indistinguishable from an attack in its effect:
# scan cadence stretches and the bot stops trading. The middleware caps
# concurrency, buckets per client, and locks out repeated failed
# authentication against /command. It fails OPEN on internal error —
# a broken limiter must not take down the page the operator diagnoses
# everything else from.
middlewares = []
try:
from modules.http_guard import make_middleware
middlewares.append(make_middleware())
except Exception as exc: # noqa: BLE001
logger.warning("[BotAPI] HTTP guard unavailable, serving unprotected: %s", exc)
app = web.Application(middlewares=middlewares)
app.router.add_get("/", handle_index)
app.router.add_get("/api/pnl", handle_api_pnl)
app.router.add_get("/api/receipts", handle_api_receipts)
app.router.add_get("/api/routes", handle_api_routes)
app.router.add_get("/api/nearmiss", handle_api_nearmiss)
app.router.add_get("/api/engine", handle_api_engine)
app.router.add_get("/api/lighthouse", handle_api_lighthouse)
app.router.add_get("/api/observatory", handle_api_observatory)
app.router.add_get("/receipts.csv", handle_receipts_csv)
app.router.add_get("/status", handle_status)
app.router.add_post("/command", handle_command)
app.router.add_get("/logs", handle_logs)
host, port = _bot_api_host(), _bot_api_port()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
logger.info("🌐 Bot API listening on %s:%d (/status, /command)", host, port)
try:
while getattr(bot, "_running", True):
await asyncio.sleep(1)
finally:
await runner.cleanup()
|