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