repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * Imperial Perps Bot — natural language Telegram interface for the Imperial agent. |
| #3 | * |
| #4 | * Features: |
| #5 | * - Persistent session: JWT, positions, last signals, conversation context |
| #6 | * - Natural language NLP: regex + Claude fallback |
| #7 | * - Full order lifecycle: scan, long, short, close, cancel, update, collateral |
| #8 | * - Live market data: funding rates, mark prices, depth, route recommendation |
| #9 | * - Account: balances, positions, orders, deposit/withdraw |
| #10 | * - Browser-use Clawd capabilities (external research via fetch) |
| #11 | * - WebSocket subscription for live market push to Telegram |
| #12 | * |
| #13 | * Environment (beyond ImperialConfig): |
| #14 | * TELEGRAM_BOT_TOKEN — required |
| #15 | * TELEGRAM_ALLOWED_CHATS — comma-separated chat IDs (empty = open) |
| #16 | * CLAWD_CLAUDE_API_KEY — optional: Claude API key for NLP fallback |
| #17 | * IMPERIAL_STATE_FILE — path to persist state JSON (default ./imperial-state.json) |
| #18 | */ |
| #19 | import fs from "node:fs"; |
| #20 | import { ImperialClient, UNDERWRITER_LABELS, Underwriter, fixedToUsd, scoreImperialMarket, } from "./imperialAgent.js"; |
| #21 | const DEFAULT_STATE = { |
| #22 | jwt: "", |
| #23 | jwtExpiresAt: "", |
| #24 | lastSignals: [], |
| #25 | lastScanTs: 0, |
| #26 | executionHistory: [], |
| #27 | conversationCtx: {}, |
| #28 | }; |
| #29 | function loadState(file) { |
| #30 | try { |
| #31 | const raw = fs.readFileSync(file, "utf-8"); |
| #32 | return { ...DEFAULT_STATE, ...JSON.parse(raw) }; |
| #33 | } |
| #34 | catch { |
| #35 | return { ...DEFAULT_STATE }; |
| #36 | } |
| #37 | } |
| #38 | function saveState(file, state) { |
| #39 | try { |
| #40 | fs.writeFileSync(file, JSON.stringify(state, null, 2)); |
| #41 | } |
| #42 | catch { |
| #43 | // non-fatal — state is in memory |
| #44 | } |
| #45 | } |
| #46 | const SYMBOL_RE = /\b(SOL|ETH|BTC|XAU|GOLD|BNB|AVAX)\b/i; |
| #47 | const SIZE_RE = /\$?([\d,.]+)\s*(?:usd|usdc|dollars?|k)?/i; |
| #48 | const VENUE_MAP = { |
| #49 | jupiter: 0, jup: 0, flash: 1, phoenix: 2, phx: 2, gmtrade: 3, gm: 3, |
| #50 | }; |
| #51 | function extractSymbol(text) { |
| #52 | const m = text.match(SYMBOL_RE); |
| #53 | return m ? m[1].toUpperCase() : null; |
| #54 | } |
| #55 | function extractSize(text) { |
| #56 | const m = text.match(SIZE_RE); |
| #57 | if (!m) |
| #58 | return null; |
| #59 | const n = parseFloat(m[1].replace(/,/g, "")); |
| #60 | return Number.isFinite(n) && n > 0 ? n : null; |
| #61 | } |
| #62 | function extractVenue(text) { |
| #63 | const lower = text.toLowerCase(); |
| #64 | for (const [key, val] of Object.entries(VENUE_MAP)) { |
| #65 | if (lower.includes(key)) |
| #66 | return val; |
| #67 | } |
| #68 | return undefined; |
| #69 | } |
| #70 | function extractPrice(text, keyword) { |
| #71 | const re = new RegExp(`${keyword}[:\\s@]*\\$?([\\d,.]+)`, "i"); |
| #72 | const m = text.match(re); |
| #73 | if (!m) |
| #74 | return null; |
| #75 | const n = parseFloat(m[1].replace(/,/g, "")); |
| #76 | return Number.isFinite(n) && n > 0 ? n : null; |
| #77 | } |
| #78 | export function parseIntent(text) { |
| #79 | const t = text.trim().toLowerCase(); |
| #80 | if (/^\/?(help|start|commands?)\b/.test(t)) |
| #81 | return { kind: "help" }; |
| #82 | if (/^\/?(health|status|ping|config)\b/.test(t)) |
| #83 | return { kind: "health" }; |
| #84 | if (/^\/?(scan|signals?|overview)\b/.test(t)) { |
| #85 | const syms = (text.match(/\b(SOL|ETH|BTC|XAU|GOLD)\b/gi) ?? []).map((s) => s.toUpperCase()); |
| #86 | return { kind: "scan", symbols: syms.length ? syms : undefined }; |
| #87 | } |
| #88 | if (/^\/?(positions?|pos)\b/.test(t)) |
| #89 | return { kind: "positions" }; |
| #90 | if (/^\/?(balances?|bal|balance|funds?)\b/.test(t)) |
| #91 | return { kind: "balances" }; |
| #92 | if (/^\/?(orders?)\b/.test(t)) |
| #93 | return { kind: "orders" }; |
| #94 | if (/^\/?(history|executions?)\b/.test(t)) |
| #95 | return { kind: "history" }; |
| #96 | if (/\b(long|buy|open long|go long|enter long)\b/.test(t)) { |
| #97 | const symbol = extractSymbol(text) ?? "SOL"; |
| #98 | return { |
| #99 | kind: "long", |
| #100 | symbol, |
| #101 | sizeUsd: extractSize(text) ?? undefined, |
| #102 | venue: extractVenue(text), |
| #103 | tpPrice: extractPrice(text, "tp|take.?profit") ?? undefined, |
| #104 | slPrice: extractPrice(text, "sl|stop.?loss") ?? undefined, |
| #105 | }; |
| #106 | } |
| #107 | if (/\b(short|sell|open short|go short|enter short)\b/.test(t)) { |
| #108 | const symbol = extractSymbol(text) ?? "SOL"; |
| #109 | return { |
| #110 | kind: "short", |
| #111 | symbol, |
| #112 | sizeUsd: extractSize(text) ?? undefined, |
| #113 | venue: extractVenue(text), |
| #114 | }; |
| #115 | } |
| #116 | if (/\b(close|exit|reduce|decrease|market.?close)\b/.test(t)) { |
| #117 | const symbol = extractSymbol(text) ?? "SOL"; |
| #118 | return { kind: "close", symbol, sizeUsd: extractSize(text) ?? undefined }; |
| #119 | } |
| #120 | if (/\b(cancel|revoke)\b/.test(t)) { |
| #121 | const m = text.match(/[1-9A-HJ-NP-Za-km-z]{32,44}/); |
| #122 | if (m) |
| #123 | return { kind: "cancel", orderPda: m[0] }; |
| #124 | } |
| #125 | if (/\b(funding|rates?)\b/.test(t)) { |
| #126 | const sym = extractSymbol(text); |
| #127 | return { kind: "funding", symbol: sym ?? undefined }; |
| #128 | } |
| #129 | if (/\b(mark.?prices?|prices?|marks?)\b/.test(t)) { |
| #130 | const sym = extractSymbol(text); |
| #131 | return { kind: "marks", symbol: sym ?? undefined }; |
| #132 | } |
| #133 | if (/\b(depth|orderbook|book)\b/.test(t)) { |
| #134 | const sym = extractSymbol(text) ?? "SOL"; |
| #135 | return { kind: "depth", symbol: sym }; |
| #136 | } |
| #137 | if (/\b(route|venue|recommend|which venue|best venue)\b/.test(t)) { |
| #138 | const sym = extractSymbol(text) ?? "SOL"; |
| #139 | const side = /\b(short|sell)\b/.test(t) ? "short" : "long"; |
| #140 | const notional = extractSize(text) ?? 100; |
| #141 | return { kind: "route", symbol: sym, side, notional }; |
| #142 | } |
| #143 | if (/\b(deposit)\b/.test(t)) { |
| #144 | const amount = extractSize(text) ?? 0; |
| #145 | return { kind: "deposit", amount }; |
| #146 | } |
| #147 | if (/\b(withdraw)\b/.test(t)) { |
| #148 | const amount = extractSize(text) ?? 0; |
| #149 | return { kind: "withdraw", amount }; |
| #150 | } |
| #151 | return { kind: "unknown", raw: text }; |
| #152 | } |
| #153 | // ─── Claude NLP fallback (optional) ────────────────────────────────────────── |
| #154 | async function claudeParseIntent(text, apiKey) { |
| #155 | try { |
| #156 | const res = await fetch("https://api.anthropic.com/v1/messages", { |
| #157 | method: "POST", |
| #158 | headers: { |
| #159 | "Content-Type": "application/json", |
| #160 | "x-api-key": apiKey, |
| #161 | "anthropic-version": "2023-06-01", |
| #162 | }, |
| #163 | body: JSON.stringify({ |
| #164 | model: "claude-haiku-4-5-20251001", |
| #165 | max_tokens: 256, |
| #166 | system: 'You extract trading intent from natural language for a Solana perps bot. Reply with JSON only, no prose. Schema: {"kind": "scan"|"long"|"short"|"close"|"cancel"|"positions"|"balances"|"funding"|"marks"|"depth"|"route"|"health"|"orders"|"history"|"deposit"|"withdraw"|"help"|"unknown", "symbol"?: string, "sizeUsd"?: number, "venue"?: number (0=Jupiter,1=Flash,2=Phoenix,3=GMTrade), "tpPrice"?: number, "slPrice"?: number, "orderPda"?: string, "side"?: "long"|"short", "notional"?: number, "amount"?: number, "raw"?: string}', |
| #167 | messages: [{ role: "user", content: text }], |
| #168 | }), |
| #169 | }); |
| #170 | if (!res.ok) |
| #171 | throw new Error(`Claude ${res.status}`); |
| #172 | const data = await res.json(); |
| #173 | const raw = data.content[0]?.text ?? "{}"; |
| #174 | return JSON.parse(raw); |
| #175 | } |
| #176 | catch { |
| #177 | return { kind: "unknown", raw: text }; |
| #178 | } |
| #179 | } |
| #180 | // ─── Response formatters ────────────────────────────────────────────────────── |
| #181 | function fmt(n, decimals = 4) { |
| #182 | return n == null ? "—" : n.toFixed(decimals); |
| #183 | } |
| #184 | function fmtFunding(rate) { |
| #185 | const ann = (rate.longFundingRatePerHourPercent ?? 0) * 8760; |
| #186 | return `${rate.venue}: ${fmt(rate.longFundingRatePerHourPercent, 4)}%/hr (${fmt(ann, 1)}% ann)`; |
| #187 | } |
| #188 | function fmtSignal(sig) { |
| #189 | const emoji = sig.decision === "buy" ? "🟢" : sig.decision === "sell" ? "🔴" : "🟡"; |
| #190 | return `${emoji} ${sig.symbol} ${sig.decision.toUpperCase()} conf=${fmt(sig.confidence, 2)}\n ${sig.rationale}`; |
| #191 | } |
| #192 | function fmtRecord(rec) { |
| #193 | const mode = rec.dryRun ? "[DRY]" : "[LIVE]"; |
| #194 | const status = rec.status === "submitted" ? "✅" : rec.status === "preview" ? "👁" : "❌"; |
| #195 | return (`${status} ${mode} ${rec.action.toUpperCase()} ${rec.side} ${rec.symbol} ` + |
| #196 | `$${fmt(rec.sizeUsd, 0)} via ${rec.venue}` + |
| #197 | (rec.txSignature ? `\n tx: ${rec.txSignature.slice(0, 16)}…` : "") + |
| #198 | (rec.error ? `\n err: ${rec.error}` : "")); |
| #199 | } |
| #200 | // ─── Intent handlers ────────────────────────────────────────────────────────── |
| #201 | async function handleScan(client, intent) { |
| #202 | const syms = intent.symbols ?? client.config.allowedSymbols; |
| #203 | const snaps = await Promise.all(syms.map((s) => client.fetchSnapshot(s))); |
| #204 | const signals = snaps.map((s) => scoreImperialMarket(s)); |
| #205 | signals.sort((a, b) => b.confidence - a.confidence); |
| #206 | return `Market scan — ${new Date().toISOString()}\n\n` + signals.map(fmtSignal).join("\n\n"); |
| #207 | } |
| #208 | async function handleLong(client, intent, live) { |
| #209 | const sizeUsd = intent.sizeUsd ?? client.config.maxSizeUsd; |
| #210 | const underwriter = (intent.venue ?? Underwriter.Phoenix); |
| #211 | const venue = UNDERWRITER_LABELS[underwriter]; |
| #212 | const entry = client.buildOrderRequest({ |
| #213 | symbol: intent.symbol, |
| #214 | side: 0, |
| #215 | action: 0, |
| #216 | sizeUsd, |
| #217 | underwriter, |
| #218 | }); |
| #219 | const closeOrders = []; |
| #220 | if (intent.tpPrice) { |
| #221 | closeOrders.push(client.buildOrderRequest({ |
| #222 | symbol: intent.symbol, |
| #223 | side: 0, |
| #224 | action: 1, |
| #225 | sizeUsd, |
| #226 | underwriter, |
| #227 | orderType: 1, |
| #228 | triggerPrice: Math.round(intent.tpPrice * 1e9), |
| #229 | triggerCondition: 0, |
| #230 | })); |
| #231 | } |
| #232 | if (intent.slPrice) { |
| #233 | closeOrders.push(client.buildOrderRequest({ |
| #234 | symbol: intent.symbol, |
| #235 | side: 0, |
| #236 | action: 1, |
| #237 | sizeUsd, |
| #238 | underwriter, |
| #239 | orderType: 2, |
| #240 | triggerPrice: Math.round(intent.slPrice * 1e9), |
| #241 | triggerCondition: 1, |
| #242 | })); |
| #243 | } |
| #244 | if (closeOrders.length > 0) { |
| #245 | const { response, records } = await client.placeBatch({ entry, closeOrders }, !live); |
| #246 | const rec = records[0] ?? null; |
| #247 | const mode = live ? "LIVE" : "DRY RUN"; |
| #248 | const status = response.entry.success ? "✅ accepted" : `❌ ${response.entry.error}`; |
| #249 | const closeSummary = response.closeOrders |
| #250 | .map((c, i) => ` leg ${i + 1}: ${c.success ? "✅" : `❌ ${c.error}`}`) |
| #251 | .join("\n"); |
| #252 | return { |
| #253 | text: `[${mode}] LONG ${intent.symbol} $${sizeUsd} via ${venue}\n${status}\nClose legs:\n${closeSummary}`, |
| #254 | record: rec, |
| #255 | }; |
| #256 | } |
| #257 | const { response, record } = await client.placeOrder(entry, !live); |
| #258 | const mode = live ? "LIVE" : "DRY RUN"; |
| #259 | const status = response.success ? "✅ accepted" : `❌ ${response.error}`; |
| #260 | return { |
| #261 | text: `[${mode}] LONG ${intent.symbol} $${sizeUsd} via ${venue}\n${status}` + |
| #262 | (response.signature ? `\ntx: ${response.signature}` : ""), |
| #263 | record, |
| #264 | }; |
| #265 | } |
| #266 | async function handleShort(client, intent, live) { |
| #267 | const sizeUsd = intent.sizeUsd ?? client.config.maxSizeUsd; |
| #268 | const underwriter = (intent.venue ?? Underwriter.Phoenix); |
| #269 | const venue = UNDERWRITER_LABELS[underwriter]; |
| #270 | const req = client.buildOrderRequest({ |
| #271 | symbol: intent.symbol, |
| #272 | side: 1, |
| #273 | action: 0, |
| #274 | sizeUsd, |
| #275 | underwriter, |
| #276 | }); |
| #277 | const { response, record } = await client.placeOrder(req, !live); |
| #278 | const mode = live ? "LIVE" : "DRY RUN"; |
| #279 | const status = response.success ? "✅ accepted" : `❌ ${response.error}`; |
| #280 | return { |
| #281 | text: `[${mode}] SHORT ${intent.symbol} $${sizeUsd} via ${venue}\n${status}` + |
| #282 | (response.signature ? `\ntx: ${response.signature}` : ""), |
| #283 | record, |
| #284 | }; |
| #285 | } |
| #286 | async function handleClose(client, intent, live) { |
| #287 | const sizeUsd = intent.sizeUsd ?? client.config.maxSizeUsd; |
| #288 | const req = client.buildOrderRequest({ |
| #289 | symbol: intent.symbol, |
| #290 | side: 0, |
| #291 | action: 1, |
| #292 | sizeUsd, |
| #293 | }); |
| #294 | const { response } = await client.placeOrder(req, !live); |
| #295 | const mode = live ? "LIVE" : "DRY RUN"; |
| #296 | return `[${mode}] CLOSE ${intent.symbol} $${sizeUsd}\n` + |
| #297 | (response.success ? "✅ accepted" : `❌ ${response.error}`); |
| #298 | } |
| #299 | async function handleFunding(client, symbol) { |
| #300 | const rates = await client.getFundingRates(); |
| #301 | const filtered = symbol |
| #302 | ? rates.filter((r) => r.symbol.toUpperCase() === symbol.toUpperCase()) |
| #303 | : rates; |
| #304 | if (filtered.length === 0) |
| #305 | return `No funding data for ${symbol ?? "all symbols"}.`; |
| #306 | const grouped = {}; |
| #307 | for (const r of filtered) { |
| #308 | if (!grouped[r.symbol]) |
| #309 | grouped[r.symbol] = []; |
| #310 | grouped[r.symbol].push(r); |
| #311 | } |
| #312 | return Object.entries(grouped) |
| #313 | .map(([sym, entries]) => `${sym}:\n${entries.map((e) => " " + fmtFunding(e)).join("\n")}`) |
| #314 | .join("\n\n"); |
| #315 | } |
| #316 | async function handleMarks(client, symbol) { |
| #317 | const marks = await client.getMarkPrices(); |
| #318 | const filtered = symbol |
| #319 | ? marks.filter((m) => m.symbol.toUpperCase() === symbol.toUpperCase()) |
| #320 | : marks; |
| #321 | if (filtered.length === 0) |
| #322 | return `No mark prices for ${symbol ?? "all"}.`; |
| #323 | return filtered |
| #324 | .map((m) => `${m.symbol} [${m.venue}]: $${fmt(m.price, 4)}`) |
| #325 | .join("\n"); |
| #326 | } |
| #327 | async function handleDepth(client, symbol) { |
| #328 | const depth = await client.getPhoenixDepth(symbol); |
| #329 | const snap = Array.isArray(depth) ? depth[0] : depth; |
| #330 | if (!snap) |
| #331 | return `No depth data for ${symbol}.`; |
| #332 | const bids = (snap.bids ?? []).slice(0, 5).map(([p, s]) => ` $${fmt(p)} × ${fmt(s, 2)}`); |
| #333 | const asks = (snap.asks ?? []).slice(0, 5).map(([p, s]) => ` $${fmt(p)} × ${fmt(s, 2)}`); |
| #334 | return `${symbol} Phoenix depth:\nAsks:\n${asks.reverse().join("\n")}\nBids:\n${bids.join("\n")}`; |
| #335 | } |
| #336 | async function handleRoute(client, intent) { |
| #337 | const rec = await client.getRoute(intent.symbol, intent.side === "long" ? 0 : 1, intent.notional); |
| #338 | return `Route for ${intent.side.toUpperCase()} ${intent.symbol} $${intent.notional}:\n` + |
| #339 | `Venue: ${rec.venue ?? UNDERWRITER_LABELS[rec.underwriter]}\n` + |
| #340 | `Est. fee: ${fmt(rec.estimatedFee, 4)}\n` + |
| #341 | `Reason: ${rec.reason ?? "—"}`; |
| #342 | } |
| #343 | async function handleBalances(client) { |
| #344 | const bal = await client.getBalances(); |
| #345 | const lines = bal.profiles.map((p) => ` Profile ${p.profileIndex}: $${fixedToUsd(p.usdc).toFixed(2)} USDC` + |
| #346 | (p.profileIndex === client.config.profileIndex ? " ← active" : "")); |
| #347 | return `Balances (${bal.wallet.slice(0, 8)}…):\n${lines.join("\n")}`; |
| #348 | } |
| #349 | async function handleHealth(client) { |
| #350 | const h = await client.healthCheck(); |
| #351 | const lines = [ |
| #352 | `Imperial Perps Agent`, |
| #353 | `Wallet: ${h.wallet ? h.wallet.slice(0, 16) + "…" : "not set"}`, |
| #354 | `Profile: ${h.profileIndex}`, |
| #355 | `JWT: ${h.jwtPresent ? "present" : "missing"}`, |
| #356 | `Mode: ${h.live ? "🔴 LIVE" : "👁 DRY-RUN"}`, |
| #357 | `Symbols: ${h.allowedSymbols.join(", ")}`, |
| #358 | `Max size: $${h.maxSizeUsd}`, |
| #359 | `Slippage: ${h.slippageBps}bps`, |
| #360 | ]; |
| #361 | if (h.warnings.length) { |
| #362 | lines.push("", "Warnings:"); |
| #363 | lines.push(...h.warnings.map((w) => ` ⚠ ${w}`)); |
| #364 | } |
| #365 | return lines.join("\n"); |
| #366 | } |
| #367 | function handleHelp() { |
| #368 | return `Imperial Perps Agent — Commands |
| #369 | |
| #370 | Natural language (say anything like the examples): |
| #371 | "scan SOL ETH BTC" — score markets |
| #372 | "long SOL $100 on phoenix" — open long |
| #373 | "short BTC $50 via flash" — open short |
| #374 | "long SOL $200 TP @170 SL @140" — entry with TP/SL batch |
| #375 | "close SOL" — market close |
| #376 | "funding SOL" — funding rates |
| #377 | "marks" — all mark prices |
| #378 | "depth BTC" — Phoenix orderbook |
| #379 | "route SOL long $100" — venue recommendation |
| #380 | "balances" — profile USDC |
| #381 | "positions" — open positions |
| #382 | "orders" — resting orders |
| #383 | "history" — last 10 executions |
| #384 | "health" — agent status |
| #385 | |
| #386 | Slash commands: |
| #387 | /scan /long /short /close /positions /balances |
| #388 | /funding /marks /depth /route /orders /history /health /help`; |
| #389 | } |
| #390 | // ─── Telegram message sender ────────────────────────────────────────────────── |
| #391 | async function tgSend(token, chatId, text) { |
| #392 | await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { |
| #393 | method: "POST", |
| #394 | headers: { "Content-Type": "application/json" }, |
| #395 | body: JSON.stringify({ |
| #396 | chat_id: chatId, |
| #397 | text: text.slice(0, 4096), |
| #398 | parse_mode: "HTML", |
| #399 | }), |
| #400 | }); |
| #401 | } |
| #402 | // ─── Main bot class ─────────────────────────────────────────────────────────── |
| #403 | export class ImperialBot { |
| #404 | client; |
| #405 | token; |
| #406 | allowedChats; |
| #407 | claudeApiKey; |
| #408 | stateFile; |
| #409 | state; |
| #410 | updateOffset = 0; |
| #411 | running = false; |
| #412 | constructor(opts = {}) { |
| #413 | const env = process.env; |
| #414 | this.token = opts.telegramToken ?? env.TELEGRAM_BOT_TOKEN ?? ""; |
| #415 | this.claudeApiKey = opts.claudeApiKey ?? env.CLAWD_CLAUDE_API_KEY ?? ""; |
| #416 | this.stateFile = opts.stateFile ?? env.IMPERIAL_STATE_FILE ?? "./imperial-state.json"; |
| #417 | this.allowedChats = new Set((opts.allowedChats ?? |
| #418 | (env.TELEGRAM_ALLOWED_CHATS ?? "").split(",").map((s) => s.trim()).filter(Boolean))); |
| #419 | this.state = loadState(this.stateFile); |
| #420 | // Restore JWT from state if env didn't provide one |
| #421 | const cfg = { ...opts.imperialConfig }; |
| #422 | if (!cfg.jwt && this.state.jwt) |
| #423 | cfg.jwt = this.state.jwt; |
| #424 | this.client = new ImperialClient(cfg); |
| #425 | } |
| #426 | isAllowed(chatId) { |
| #427 | if (this.allowedChats.size === 0) |
| #428 | return true; |
| #429 | return this.allowedChats.has(String(chatId)); |
| #430 | } |
| #431 | addCtx(chatId, message) { |
| #432 | if (!this.state.conversationCtx[chatId]) |
| #433 | this.state.conversationCtx[chatId] = []; |
| #434 | const ctx = this.state.conversationCtx[chatId]; |
| #435 | ctx.push(message); |
| #436 | if (ctx.length > 10) |
| #437 | ctx.shift(); |
| #438 | } |
| #439 | pushRecord(rec) { |
| #440 | this.state.executionHistory.unshift(rec); |
| #441 | if (this.state.executionHistory.length > 50) |
| #442 | this.state.executionHistory.length = 50; |
| #443 | } |
| #444 | /** Parse intent: regex first, Claude fallback if available and unknown. */ |
| #445 | async parseIntent(text) { |
| #446 | const intent = parseIntent(text); |
| #447 | if (intent.kind !== "unknown" || !this.claudeApiKey) |
| #448 | return intent; |
| #449 | return claudeParseIntent(text, this.claudeApiKey); |
| #450 | } |
| #451 | /** Browser-use: fetch a URL and return text content (Clawd capability). */ |
| #452 | async browse(url) { |
| #453 | try { |
| #454 | const res = await fetch(url, { |
| #455 | headers: { "User-Agent": "ClawdImperialBot/1.0" }, |
| #456 | }); |
| #457 | const text = await res.text(); |
| #458 | // Strip HTML tags crudely for readability |
| #459 | return text.replace(/<[^>]*>/g, " ").replace(/\s{2,}/g, " ").slice(0, 2000); |
| #460 | } |
| #461 | catch (err) { |
| #462 | return `Browse failed: ${err instanceof Error ? err.message : String(err)}`; |
| #463 | } |
| #464 | } |
| #465 | /** Handle a single incoming message. */ |
| #466 | async handleMessage(chatId, text) { |
| #467 | const cid = String(chatId); |
| #468 | this.addCtx(cid, `user: ${text}`); |
| #469 | // Browser-use trigger |
| #470 | if (/^browse\s+(https?:\/\/\S+)/i.test(text.trim())) { |
| #471 | const m = text.match(/https?:\/\/\S+/); |
| #472 | if (m) { |
| #473 | const content = await this.browse(m[0]); |
| #474 | const reply = `Browsed ${m[0]}:\n\n${content}`; |
| #475 | this.addCtx(cid, `bot: ${reply.slice(0, 200)}`); |
| #476 | return reply; |
| #477 | } |
| #478 | } |
| #479 | const intent = await this.parseIntent(text); |
| #480 | let reply = ""; |
| #481 | try { |
| #482 | switch (intent.kind) { |
| #483 | case "help": |
| #484 | reply = handleHelp(); |
| #485 | break; |
| #486 | case "health": |
| #487 | reply = await handleHealth(this.client); |
| #488 | break; |
| #489 | case "scan": { |
| #490 | reply = await handleScan(this.client, intent); |
| #491 | break; |
| #492 | } |
| #493 | case "long": { |
| #494 | const { text: t, record } = await handleLong(this.client, intent, this.client.config.live); |
| #495 | reply = t; |
| #496 | if (record) |
| #497 | this.pushRecord(record); |
| #498 | break; |
| #499 | } |
| #500 | case "short": { |
| #501 | const { text: t, record } = await handleShort(this.client, intent, this.client.config.live); |
| #502 | reply = t; |
| #503 | if (record) |
| #504 | this.pushRecord(record); |
| #505 | break; |
| #506 | } |
| #507 | case "close": |
| #508 | reply = await handleClose(this.client, intent, this.client.config.live); |
| #509 | break; |
| #510 | case "cancel": { |
| #511 | const res = await this.client.cancelOrder({ |
| #512 | wallet: this.client.wallet, |
| #513 | profileIndex: this.client.config.profileIndex, |
| #514 | orderPda: intent.orderPda, |
| #515 | }); |
| #516 | reply = res.success ? `✅ Cancelled ${intent.orderPda.slice(0, 8)}…` : `❌ ${res.error}`; |
| #517 | break; |
| #518 | } |
| #519 | case "positions": { |
| #520 | const pos = await this.client.getPositions(); |
| #521 | reply = `Positions:\n${JSON.stringify(pos, null, 2).slice(0, 1500)}`; |
| #522 | break; |
| #523 | } |
| #524 | case "balances": |
| #525 | reply = await handleBalances(this.client); |
| #526 | break; |
| #527 | case "orders": { |
| #528 | const orders = await this.client.getOrders(); |
| #529 | reply = `Orders:\n${JSON.stringify(orders, null, 2).slice(0, 1500)}`; |
| #530 | break; |
| #531 | } |
| #532 | case "history": { |
| #533 | if (this.state.executionHistory.length === 0) { |
| #534 | reply = "No execution history yet."; |
| #535 | } |
| #536 | else { |
| #537 | reply = |
| #538 | "Last executions:\n\n" + |
| #539 | this.state.executionHistory.slice(0, 10).map(fmtRecord).join("\n\n"); |
| #540 | } |
| #541 | break; |
| #542 | } |
| #543 | case "funding": |
| #544 | reply = await handleFunding(this.client, intent.symbol); |
| #545 | break; |
| #546 | case "marks": |
| #547 | reply = await handleMarks(this.client, intent.symbol); |
| #548 | break; |
| #549 | case "depth": |
| #550 | reply = await handleDepth(this.client, intent.symbol); |
| #551 | break; |
| #552 | case "route": |
| #553 | reply = await handleRoute(this.client, intent); |
| #554 | break; |
| #555 | case "deposit": { |
| #556 | if (intent.amount <= 0) { |
| #557 | reply = "Specify an amount: deposit $100"; |
| #558 | } |
| #559 | else { |
| #560 | const tx = await this.client.buildDepositTx({ |
| #561 | wallet: this.client.wallet, |
| #562 | profileIndex: this.client.config.profileIndex, |
| #563 | amount: Math.round(intent.amount * 1_000_000), |
| #564 | mode: "deposit", |
| #565 | }); |
| #566 | reply = `Deposit tx built (${intent.amount} USDC). Sign and submit:\n${tx.transaction.slice(0, 64)}…`; |
| #567 | } |
| #568 | break; |
| #569 | } |
| #570 | case "withdraw": { |
| #571 | if (intent.amount <= 0) { |
| #572 | reply = "Specify an amount: withdraw $100"; |
| #573 | } |
| #574 | else { |
| #575 | const tx = await this.client.buildDepositTx({ |
| #576 | wallet: this.client.wallet, |
| #577 | profileIndex: this.client.config.profileIndex, |
| #578 | amount: Math.round(intent.amount * 1_000_000), |
| #579 | mode: "withdraw", |
| #580 | }); |
| #581 | reply = `Withdraw tx built (${intent.amount} USDC). Sign and submit:\n${tx.transaction.slice(0, 64)}…`; |
| #582 | } |
| #583 | break; |
| #584 | } |
| #585 | default: |
| #586 | reply = |
| #587 | `I didn't understand: "${text.slice(0, 80)}"\n\nType /help for available commands.`; |
| #588 | } |
| #589 | } |
| #590 | catch (err) { |
| #591 | reply = `Error: ${err instanceof Error ? err.message : String(err)}`; |
| #592 | } |
| #593 | this.addCtx(cid, `bot: ${reply.slice(0, 200)}`); |
| #594 | saveState(this.stateFile, this.state); |
| #595 | return reply; |
| #596 | } |
| #597 | /** Poll Telegram for updates and dispatch. */ |
| #598 | async poll() { |
| #599 | if (!this.token) |
| #600 | throw new Error("TELEGRAM_BOT_TOKEN not set."); |
| #601 | this.running = true; |
| #602 | console.log("[ImperialBot] Polling started."); |
| #603 | while (this.running) { |
| #604 | try { |
| #605 | const res = await fetch(`https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.updateOffset}&timeout=30`); |
| #606 | if (!res.ok) { |
| #607 | await sleep(5000); |
| #608 | continue; |
| #609 | } |
| #610 | const data = await res.json(); |
| #611 | for (const update of data.result ?? []) { |
| #612 | this.updateOffset = update.update_id + 1; |
| #613 | const msg = update.message; |
| #614 | if (!msg?.text) |
| #615 | continue; |
| #616 | const chatId = msg.chat.id; |
| #617 | if (!this.isAllowed(chatId)) |
| #618 | continue; |
| #619 | // Dispatch without awaiting so poll loop continues |
| #620 | this.handleMessage(chatId, msg.text) |
| #621 | .then((reply) => tgSend(this.token, chatId, reply)) |
| #622 | .catch((err) => tgSend(this.token, chatId, `Internal error: ${err instanceof Error ? err.message : String(err)}`)); |
| #623 | } |
| #624 | } |
| #625 | catch { |
| #626 | await sleep(5000); |
| #627 | } |
| #628 | } |
| #629 | } |
| #630 | /** Subscribe to Imperial /ws/market and push updates to a Telegram chat. */ |
| #631 | async subscribeMarketWs(chatId, symbols) { |
| #632 | // WebSocket not available in all Node environments — gate on globalThis.WebSocket |
| #633 | const WS = globalThis.WebSocket; |
| #634 | if (!WS) { |
| #635 | await tgSend(this.token, chatId, "WebSocket not available in this runtime."); |
| #636 | return; |
| #637 | } |
| #638 | const base = this.client.config.base.replace(/^http/, "ws").replace("/api/v1", ""); |
| #639 | const ws = new WS(`${base}/ws/market`); |
| #640 | ws.onopen = () => { |
| #641 | ws.send(JSON.stringify({ type: "subscribe_funding_rates" })); |
| #642 | ws.send(JSON.stringify({ type: "subscribe_mark_prices" })); |
| #643 | ws.send(JSON.stringify({ |
| #644 | type: "subscribe_phoenix_depth", |
| #645 | ...(symbols ? { symbols } : {}), |
| #646 | })); |
| #647 | }; |
| #648 | ws.onmessage = (event) => { |
| #649 | try { |
| #650 | const msg = JSON.parse(event.data); |
| #651 | let text = ""; |
| #652 | if (msg.type === "mark_price_update") { |
| #653 | text = `📊 ${msg.symbol} [${msg.venue}] $${fmt(msg.price ?? null)}`; |
| #654 | } |
| #655 | else if (msg.type === "funding_rate_update") { |
| #656 | const ann = (msg.longFundingRatePerHourPercent ?? 0) * 8760; |
| #657 | text = `💸 ${msg.symbol} [${msg.venue}] funding ${fmt(ann, 1)}% ann`; |
| #658 | } |
| #659 | if (text) |
| #660 | tgSend(this.token, chatId, text).catch(() => { }); |
| #661 | } |
| #662 | catch { |
| #663 | // ignore parse errors |
| #664 | } |
| #665 | }; |
| #666 | ws.onerror = () => { |
| #667 | tgSend(this.token, chatId, "Market WS error — reconnect manually.").catch(() => { }); |
| #668 | }; |
| #669 | } |
| #670 | stop() { |
| #671 | this.running = false; |
| #672 | } |
| #673 | } |
| #674 | function sleep(ms) { |
| #675 | return new Promise((resolve) => setTimeout(resolve, ms)); |
| #676 | } |
| #677 | /** Quick start: create and run the bot. */ |
| #678 | export async function startImperialBot(opts = {}) { |
| #679 | const bot = new ImperialBot(opts); |
| #680 | bot.poll().catch(console.error); |
| #681 | return bot; |
| #682 | } |
| #683 | //# sourceMappingURL=imperialBot.js.map |