agus1111 commited on
Commit
9da2f9d
Β·
verified Β·
1 Parent(s): 167b5f1

Update autotrack.py

Browse files
Files changed (1) hide show
  1. autotrack.py +274 -446
autotrack.py CHANGED
@@ -1,497 +1,325 @@
1
- # autotrack.py β€” Unlimited milestones + auto-delete on drawdown + video β‰₯3Γ—
2
  import os
3
  import re
4
- import time
5
  import asyncio
6
- import aiohttp
7
- import sqlite3
8
  from dataclasses import dataclass, field
9
- from typing import Optional, Dict, Tuple
10
- from datetime import datetime, timezone, timedelta
11
 
 
12
  from telethon import TelegramClient, events
13
- from telethon.sessions import StringSession, MemorySession
14
 
15
  # =========================
16
- # ENV / CONFIG
17
  # =========================
18
- API_ID = int(os.environ.get("API_ID", "0"))
19
- API_HASH = os.environ.get("API_HASH", "")
20
- STRING_SESSION = os.environ.get("STRING_SESSION", "")
21
-
22
- # Announce ke grup kamu (dipakai juga sebagai sumber pesan)
23
- TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
24
-
25
- # Polling interval harga
26
- TRACK_POLL_SECS = int(os.environ.get("TRACK_POLL_SECS", "20"))
27
 
28
- # Ambang reply awal (default 1.5Γ—)
29
- REPLY_FROM_MULTIPLE = float(os.environ.get("REPLY_FROM_MULTIPLE", "1.5"))
 
30
 
31
- # Unlimited mode: selalu lanjut 2Γ—, 3Γ—, 4Γ—, ...
32
- # Tidak berhenti otomatis
33
- STOP_WHEN_HIT = False
34
 
35
- # Abaikan pesan lebih tua dari (startup - buffer)
36
- BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
37
 
38
- # Marker antirecursive: ditambahkan ke semua pengumuman bot (milestone)
39
- BOT_MARKER = "【MT-AUTOTRACK】"
40
 
41
- # Lokasi DB milik botsignal (untuk ambil msg_id pesan awal)
42
- BOTSIGNAL_DB = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db")
43
 
44
- # NEW: Auto-delete bila drawdown >= threshold (rasio relatif terhadap entry)
45
- # Contoh: 0.5 berarti -50%. Set 0 untuk mematikan.
46
- DROP_DELETE_RATIO = float(os.environ.get("DROP_DELETE_RATIO", "0.5"))
47
 
48
- # (opsional) juga hapus thread balasan bot yang bertanda BOT_MARKER
49
- DELETE_THREAD_REPLIES = os.environ.get("DELETE_THREAD_REPLIES", "0") == "1"
50
 
51
- # >>> VIDEO milestone β‰₯ 3Γ—
52
- # Nama file video di root repo (sefolder dengan autotrack.py). Ubah via env bila perlu.
53
- MILESTONE_VIDEO = os.environ.get("MILESTONE_VIDEO", "Generating_To_The_Moon_Animation.mp4")
54
- VIDEO_PATH = os.path.join(os.path.dirname(__file__), MILESTONE_VIDEO)
55
- VIDEO_MIN_MULTIPLE = float(os.environ.get("VIDEO_MIN_MULTIPLE", "3.0")) # mulai kirim video dari x berapa
56
 
57
- # =========================
58
- # HTTP endpoints
59
- # =========================
60
- DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
61
- JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- # =========================
64
- # Helpers
65
- # =========================
66
- def _fmt_money(x: Optional[float]) -> str:
67
- if x is None:
68
- return "?"
69
- if x >= 1:
70
- return f"${x:,.4f}"
71
- return f"${x:.8f}"
72
-
73
- def _fmt_big(x: Optional[float]) -> str:
74
- if x is None:
75
- return "?"
76
- if x >= 1_000_000_000:
77
- return f"${x/1_000_000_000:.2f}B"
78
- if x >= 1_000_000:
79
- return f"${x/1_000_000:.2f}M"
80
- if x >= 1_000:
81
- return f"${x/1_000:.2f}K"
82
- return f"${x:.0f}"
83
-
84
- def _milestone_badge(m: float, basis: str) -> str:
 
 
 
 
85
  """
86
- Balikin judul milestone yang menonjol untuk semua level.
87
- basis: "mcap" atau "price"
88
  """
89
- unit = "Market Cap" if basis == "mcap" else "Price"
90
-
91
- # kategorisasi level agar konsisten & gampang dibaca
92
- if m < 2.0 - 1e-9: # 1.5Γ— (reply awal)
93
- return f"🟨 **ARMED β€” {m:.1f}Γ— {unit}**"
94
- elif abs(m - 2.0) < 1e-9: # 2Γ—
95
- return f"🟩 **DOUBLE UP β€” 2Γ— {unit}!**"
96
- elif abs(m - 3.0) < 1e-9: # 3Γ—
97
- return f"πŸ”· **TRIPLE β€” 3Γ— {unit}!**"
98
- elif 3.0 < m < 6.0: # 4–5Γ—
99
- return f"πŸ”Ά **RALLY β€” {m:.0f}Γ— {unit}!**"
100
- elif 6.0 <= m < 10.0: # 6–9Γ—
101
- return f"πŸŸ₯ **OVERDRIVE β€” {m:.0f}Γ— {unit}!**"
102
- else: # 10Γ—+
103
- return f"🟣 **GOD CANDLE β€” {m:.0f}Γ— {unit}!!**"
104
-
105
- # =========================
106
- # CA extraction
107
- # =========================
108
- CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (approx)
109
- CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM 0x...
110
-
111
- CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
112
-
113
- def extract_ca(text: str) -> Optional[str]:
114
- """Return first found CA (EVM or Solana). Prefer 'CA: <addr>' label if present."""
115
- if not text:
116
  return None
117
- m = CA_LABEL_RE.search(text)
118
- if m:
119
- cand = m.group(1)
120
- if CA_EVM_RE.fullmatch(cand):
121
- return cand.lower()
122
- if CA_SOL_RE.fullmatch(cand):
123
- return cand
124
- m2 = CA_EVM_RE.search(text or "")
125
- if m2:
126
- return m2.group(0).lower()
127
- m3 = CA_SOL_RE.search(text or "")
128
- if m3:
129
- return m3.group(0)
130
- return None
131
-
132
- def ca_key_for_db(ca: str) -> Optional[str]:
133
- if not ca:
134
  return None
135
- if CA_EVM_RE.fullmatch(ca):
136
- return f"ca:evm:{ca.lower()}"
137
- if CA_SOL_RE.fullmatch(ca):
138
- return f"ca:sol:{ca}"
139
- return None
140
 
141
- def lookup_origin_msg_id(keyword: str) -> Optional[int]:
142
- """Ambil msg_id pertama dari DB botsignal untuk keyword entitas CA."""
143
- try:
144
- conn = sqlite3.connect(BOTSIGNAL_DB)
145
- cur = conn.cursor()
146
- cur.execute("SELECT msg_id FROM last_posted WHERE keyword = ?", (keyword,))
147
- row = cur.fetchone()
148
- conn.close()
149
- if row and isinstance(row[0], int):
150
- return row[0]
 
 
 
151
  return None
152
- except Exception as e:
153
- print(f"[TRACK][DB] lookup error: {e}")
 
154
  return None
155
 
156
  # =========================
157
- # Tracker core (UNLIMITED)
158
  # =========================
159
  @dataclass
160
  class TrackItem:
161
  ca: str
162
- basis: str = "mcap" # "mcap" (default) or "price"
163
- entry_price: Optional[float] = None
164
- entry_mcap: Optional[float] = None
165
- symbol_hint: Optional[str] = None
166
- source_link: Optional[str] = None
167
- poll_secs: int = 20
168
- next_milestone: float = field(default_factory=lambda: REPLY_FROM_MULTIPLE)
169
- started_at: float = field(default_factory=time.time)
170
-
171
- class PriceTracker:
172
- def __init__(self, client: TelegramClient, announce_chat):
173
- self.client = client
174
- self.announce_chat = announce_chat
175
- self._tasks: Dict[str, asyncio.Task] = {}
176
- self._items: Dict[str, TrackItem] = {}
177
-
178
- # -------- HTTP helpers --------
179
- async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
180
- tout = aiohttp.ClientTimeout(total=timeout)
181
- async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
182
- async with sess.get(url) as r:
183
- if r.status != 200:
184
- return None
185
- return await r.json()
186
-
187
- async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
188
- data = await self._get(DEXSCREENER_TOKEN_URL + ca)
189
- if not data:
190
- return None
191
- pairs = data.get("pairs") or []
192
- if not pairs:
193
- return None
194
- best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
195
- price = float(best.get("priceUsd")) if best.get("priceUsd") else None
196
- symbol = ((best.get("baseToken") or {}).get("symbol")) or None
197
-
198
- mcap = None
199
- fdv = best.get("fdv")
200
- if isinstance(fdv, (int, float)) and fdv > 0:
201
- mcap = float(fdv)
202
- mc = best.get("marketCap")
203
- if isinstance(mc, (int, float)) and mc > 0:
204
- mcap = float(mc)
205
- return (price, symbol, mcap)
206
-
207
- async def _jupiter_price(self, ca: str) -> Optional[float]:
208
- try:
209
- data = await self._get(JUPITER_URL + ca)
210
- if not data:
211
- return None
212
- d = (data.get("data") or {}).get(ca)
213
- if not d:
214
- return None
215
- p = d.get("price")
216
- return float(p) if p is not None else None
217
- except:
218
- return None
219
-
220
- async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
221
- res = await self._dexscreener_price(ca)
222
- if res:
223
- price, sym, mcap = res
224
- if price is None:
225
- jp = await self._jupiter_price(ca)
226
- price = jp if jp is not None else None
227
- return {"price": price, "mcap": mcap, "symbol_hint": sym}
228
- jp = await self._jupiter_price(ca)
229
- if jp is not None:
230
- return {"price": jp, "mcap": None, "symbol_hint": None}
231
- return None
232
-
233
- # -------- Announce helpers --------
234
- def _name(self, item: TrackItem) -> str:
235
- s = item.symbol_hint or ""
236
- return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
237
 
238
- async def _send_reply_to_origin(self, ca: str, text: str):
239
- try:
240
- key = ca_key_for_db(ca)
241
- reply_to_id = lookup_origin_msg_id(key) if key else None
242
- if reply_to_id:
243
- await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", reply_to=reply_to_id, link_preview=False)
244
- else:
245
- await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", link_preview=False)
246
- except Exception as e:
247
- print(f"[TRACK] reply failed: {e}")
248
-
249
- def _milestone_text(self, item: TrackItem, m: float, ratio: float,
250
- cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
251
- dexs_link = f"https://dexscreener.com/solana/{item.ca}"
252
- axiom_link = "https://axiom.trade/@1144321"
253
- if item.basis == "mcap":
254
- change = f"{_fmt_big(item.entry_mcap)} β†’ {_fmt_big(cur_mcap)} (~{ratio:.2f}Γ—)"
255
- else:
256
- change = f"{_fmt_money(item.entry_price)} β†’ {_fmt_money(cur_price)} (~{ratio:.2f}Γ—)"
257
- milestone_line = _milestone_badge(m, item.basis)
258
- elapsed = int(time.time() - item.started_at)
259
- mm, ss = divmod(elapsed, 60)
260
- header = "πŸ’Ž [MidasTouch Signal] πŸ’Ž\n━━━━━━━━━━━━━━━━"
261
- footer = "━━━━━━━━━━━━━━━━"
262
- body = (
263
- f"{header}\n"
264
- f"{milestone_line}\n"
265
- f"{self._name(item)}\n"
266
- f"{change}\n"
267
- f"⏱️ {mm}m {ss}s since call\n"
268
- f"πŸ”Ž [Dexscreener]({dexs_link})\n"
269
- f"πŸ›’ [Trade on Axiom]({axiom_link})\n"
270
- f"CA: `{item.ca}`\n"
271
- f"{footer}"
272
- )
273
- return body
274
-
275
- def _next_target_after(self, current_target: float) -> float:
276
- # Setelah 1.5Γ—, lanjut integer berikutnya: 2Γ—, 3Γ—, 4Γ—, ...
277
- return 2.0 if current_target < 2.0 else current_target + 1.0
278
-
279
- async def _delete_origin_and_replies(self, ca: str):
280
- """Hapus pesan origin di TARGET_CHAT (dan opsional balasan bot)."""
281
- try:
282
- key = ca_key_for_db(ca)
283
- msg_id = lookup_origin_msg_id(key) if key else None
284
- if not msg_id:
285
  return
286
- # Hapus pesan origin
287
- await self.client.delete_messages(self.announce_chat, msg_id)
288
- print(f"[TRACK] Deleted origin message for {ca}")
289
-
290
- if DELETE_THREAD_REPLIES:
291
- # Coba ambil beberapa pesan terakhir & hapus balasan bot yang menandai BOT_MARKER dan reply_to origin
292
- try:
293
- async for m in self.client.iter_messages(self.announce_chat, limit=100, reply_to=msg_id):
294
- txt = (m.message or "") if hasattr(m, "message") else ""
295
- if BOT_MARKER in (txt or ""):
296
- await self.client.delete_messages(self.announce_chat, m.id)
297
- print(f"[TRACK] Deleted thread replies for {ca}")
298
- except Exception as e:
299
- print(f"[TRACK] delete thread replies failed: {e}")
300
- except Exception as e:
301
- print(f"[TRACK] delete origin failed: {e}")
302
-
303
- # -------- Loop --------
304
- async def _loop(self, item: TrackItem):
305
- snap = await self._get_snapshot(item.ca)
306
- if not snap:
307
- print(f"[TRACK] init snapshot failed for {item.ca}")
308
- return
309
 
310
- if item.entry_price is None:
311
- item.entry_price = snap.get("price")
312
- if item.entry_mcap is None:
313
- item.entry_mcap = snap.get("mcap")
314
- if not item.symbol_hint and snap.get("symbol_hint"):
315
- item.symbol_hint = snap.get("symbol_hint")
316
 
317
- if item.basis == "mcap" and not item.entry_mcap:
318
- item.basis = "price"
 
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  while True:
321
- snap = await self._get_snapshot(item.ca)
322
- if not snap:
323
- await asyncio.sleep(item.poll_secs)
324
- continue
325
-
326
- cur_price = snap.get("price")
327
- cur_mcap = snap.get("mcap")
328
- if not item.symbol_hint and snap.get("symbol_hint"):
329
- item.symbol_hint = snap.get("symbol_hint")
330
-
331
- # Hitung rasio vs entry
332
- if item.basis == "mcap":
333
- if not (item.entry_mcap and cur_mcap):
334
- await asyncio.sleep(item.poll_secs)
335
  continue
336
- ratio = (cur_mcap / item.entry_mcap) if item.entry_mcap > 0 else 0.0
337
- else:
338
- if not (item.entry_price and cur_price):
339
- await asyncio.sleep(item.poll_secs)
 
 
340
  continue
341
- ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
342
-
343
- # === NEW: auto delete kalau drop >= threshold ===
344
- # ratio <= 0.5 berarti -50% dari entry (default)
345
- if DROP_DELETE_RATIO > 0 and ratio <= DROP_DELETE_RATIO:
346
- await self._delete_origin_and_replies(item.ca)
347
- return # stop tracking setelah dihapus
348
-
349
- # --- milestone handling ---
350
- while ratio >= item.next_milestone:
351
- text = self._milestone_text(item, item.next_milestone, ratio, cur_price, cur_mcap)
352
- key = ca_key_for_db(item.ca)
353
- reply_to_id = lookup_origin_msg_id(key) if key else None
354
- # Gabungkan jadi 1 pesan: kalau β‰₯ VIDEO_MIN_MULTIPLE dan ada file β†’ kirim video dengan caption = text
355
- if item.next_milestone >= VIDEO_MIN_MULTIPLE and os.path.isfile(VIDEO_PATH):
356
- try:
357
- await self.client.send_file(
358
- self.announce_chat,
359
- VIDEO_PATH,
360
- caption=f"{text}\n\n{BOT_MARKER}",
361
- reply_to=reply_to_id if reply_to_id else None,
362
- force_document=False
363
- )
364
- except Exception as e:
365
- print(f"[TRACK] gagal kirim video: {e}")
366
- else:
367
- await self.client.send_message(
368
- self.announce_chat,
369
- f"{text}\n\n{BOT_MARKER}",
370
- reply_to=reply_to_id if reply_to_id else None,
371
- link_preview=False
372
- )
373
- # Naikkan target ke milestone berikut
374
- item.next_milestone = self._next_target_after(item.next_milestone)
375
-
376
- await asyncio.sleep(item.poll_secs)
377
-
378
- # -------- Public --------
379
- def is_tracking(self, ca: str) -> bool:
380
- t = self._tasks.get(ca)
381
- return bool(t) and not t.done()
382
-
383
- async def start(self, ca: str, *, basis: str = "mcap",
384
- entry_price: Optional[float] = None,
385
- entry_mcap: Optional[float] = None,
386
- poll_secs: int = 20,
387
- source_link: Optional[str] = None,
388
- symbol_hint: Optional[str] = None):
389
- ca = ca.strip()
390
- if self.is_tracking(ca):
391
- return
392
- item = TrackItem(
393
- ca=ca,
394
- basis=basis.lower(),
395
- entry_price=entry_price,
396
- entry_mcap=entry_mcap,
397
- poll_secs=poll_secs,
398
- source_link=source_link,
399
- symbol_hint=symbol_hint,
400
- next_milestone=REPLY_FROM_MULTIPLE,
401
- )
402
- self._tasks[ca] = asyncio.create_task(self._loop(item))
403
 
 
 
 
 
404
 
405
- # =========================
406
- # Setup: attach to existing client from botsignal
407
- # =========================
408
- def setup_autotrack(shared_client: TelegramClient, announce_chat: str | None = None):
409
- global client, tracker, startup_time_utc
410
- client = shared_client
411
- ac = announce_chat or TARGET_CHAT
412
- tracker = PriceTracker(client, announce_chat=ac)
413
- startup_time_utc = datetime.now(timezone.utc)
414
- client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
415
- print("[AUTOTRACK] attached to shared client; listening on", TARGET_CHAT)
416
 
417
- # =========================
418
- # Client bootstrap
419
- # =========================
420
- def build_client() -> TelegramClient:
421
- if STRING_SESSION:
422
- print(">> Using StringSession (persistent).")
423
- return TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH)
424
- print(">> Using MemorySession (login each run).")
425
- return TelegramClient(MemorySession(), API_ID, API_HASH)
426
-
427
- client: TelegramClient | None = None
428
- startup_time_utc = None
429
- me_user_id: Optional[int] = None
430
- tracker: PriceTracker | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  # =========================
433
- # Event handler: ONLY your group
434
  # =========================
435
- CUTOFF_BUFFER = timedelta(minutes=BACKFILL_BUFFER_MINUTES)
 
 
436
 
437
- def _is_old_message(msg_dt: Optional[datetime]) -> bool:
438
- if not isinstance(msg_dt, datetime):
439
- return False
440
- return msg_dt.replace(tzinfo=timezone.utc) < (startup_time_utc - CUTOFF_BUFFER)
441
 
442
- def _is_bot_own_message(event) -> bool:
443
- txt = (event.raw_text or "") if hasattr(event, "raw_text") else ""
444
- return BOT_MARKER in (txt or "")
 
445
 
446
- async def on_new_message(event):
447
- try:
448
- if _is_bot_own_message(event):
449
- return
450
- msg = event.message
451
- if _is_old_message(getattr(msg, "date", None)):
452
- return
453
- text = msg.message or (getattr(msg, "raw_text", None) or "")
454
- ca = extract_ca(text)
455
- if not ca:
456
- return
457
- # Optional: link back to source (tidak dipakai di teks sekarang, tapi disimpan kalau perlu)
458
- source_link = None
459
- try:
460
- chat = await event.get_chat()
461
- uname = getattr(chat, "username", None)
462
- mid = getattr(msg, "id", None)
463
- if uname and mid:
464
- source_link = f"https://t.me/{uname}/{mid}"
465
- except:
466
- pass
467
- await tracker.start(
468
- ca=ca,
469
- basis="mcap",
470
- poll_secs=TRACK_POLL_SECS,
471
- source_link=source_link,
472
- )
473
- except Exception as e:
474
- print(f"[AUTOTRACK] error: {e}")
475
-
476
- # =========================
477
- # Entrypoint
478
- # =========================
479
- async def main():
480
- global client, tracker, startup_time_utc
481
- if client is None:
482
- client = build_client()
483
- startup_time_utc = datetime.now(timezone.utc)
484
- tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
485
- await client.start()
486
- try:
487
- me = await client.get_me()
488
- globals()["me_user_id"] = int(getattr(me, "id", 0))
489
- print(f">> Logged in as: {getattr(me, 'username', None) or me_user_id}")
490
- except Exception as e:
491
- print(f"Warning: cannot resolve self id: {e}")
492
- print(f"AutoTrack running (unlimited milestones; first reply β‰₯ {REPLY_FROM_MULTIPLE}Γ—). Listening ONLY your group for NEW messages...")
493
- client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
494
- await client.run_until_disconnected()
495
 
496
  if __name__ == "__main__":
497
- asyncio.run(main())
 
1
+ # autotrack.py
2
  import os
3
  import re
4
+ import math
5
  import asyncio
 
 
6
  from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Dict, Optional, List
9
 
10
+ import aiohttp
11
  from telethon import TelegramClient, events
 
12
 
13
  # =========================
14
+ # === CONFIG / ENV VARS ===
15
  # =========================
16
+ API_ID = int(os.getenv("TELEGRAM_API_ID", "0"))
17
+ API_HASH = os.getenv("TELEGRAM_API_HASH", "")
18
+ SESSION = os.getenv("TELEGRAM_SESSION", "autotrack.session")
 
 
 
 
 
 
19
 
20
+ # Chat tempat botsignal mem-post (sumber) dan tempat reply milestone (biasanya sama)
21
+ SOURCE_CHAT = os.getenv("SOURCE_CHAT", "@MidasTouchsignalll")
22
+ ANNOUNCE_CHAT = os.getenv("ANNOUNCE_CHAT", SOURCE_CHAT)
23
 
24
+ # Milestones (float, dipisah koma) β€” default: 1.5Γ—, 2Γ—, 3Γ—, 5Γ—, 10Γ—, 20Γ—
25
+ MILESTONES_ENV = os.getenv("MILESTONES", "1.5,2,3,5,10,20")
26
+ MILESTONES: List[float] = sorted({float(x.strip()) for x in MILESTONES_ENV.split(",") if x.strip()})
27
 
28
+ # Mulai arm di 1.5Γ— dihitung dari ENTRY (bisa abaikan; milestone pertama biasanya >= 1.5)
29
+ START_MULTIPLIER = float(os.getenv("START_MULTIPLIER", "1.5"))
30
 
31
+ # Auto-delete saat turun X% dari entry. 0 = mati. Contoh 0.5 = hapus saat -50%
32
+ DROP_DELETE_RATIO = float(os.getenv("DROP_DELETE_RATIO", "0"))
33
 
34
+ # Ref Axiom (opsional)
35
+ AXIOM_REF = os.getenv("AXIOM_REF", "https://axiom.trade/@1144321")
36
 
37
+ # Interval cek harga (detik)
38
+ POLL_SEC = int(os.getenv("POLL_SEC", "30"))
 
39
 
40
+ # Aktifkan preview tautan?
41
+ LINK_PREVIEW = os.getenv("LINK_PREVIEW", "true").lower() not in {"0", "false", "no"}
42
 
43
+ BOT_MARKER = "【MT-AUTOTRACK】"
 
 
 
 
44
 
45
+ # ===========================
46
+ # === REGEX & PARSE UTILS ===
47
+ # ===========================
48
+ # EVM (0x...) & Solana (base58 32-44) + dukung pump.fun akhiran 'pump'
49
+ CA_EVM_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
50
+ CA_SOL_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}(?:pump)?$")
51
+
52
+ # Cari CA di teks botsignal (baris 'CA: <addr>')
53
+ CA_LINE_RE = re.compile(r"(?im)^\s*CA\s*:\s*`?([^\s`]+)`?\s*$")
54
+
55
+ # Cari angka Market Cap di teks botsignal: "$213.97K β†’ $325.34K" atau "$213.97K"
56
+ MCAP_NUM_RE = re.compile(r"\$([\d.,]+)\s*([KMB])?", re.I)
57
+
58
+ def _to_number(amt: str, suffix: Optional[str]) -> float:
59
+ """Convert $213.9K/$2.1M/$1.2B to float USD."""
60
+ x = float(amt.replace(",", ""))
61
+ mult = 1.0
62
+ if suffix:
63
+ s = suffix.upper()
64
+ if s == "K": mult = 1e3
65
+ elif s == "M": mult = 1e6
66
+ elif s == "B": mult = 1e9
67
+ return x * mult
68
+
69
+ def _extract_first_mcap_usd(text: str) -> Optional[float]:
70
+ m = MCAP_NUM_RE.search(text)
71
+ if not m:
72
+ return None
73
+ return _to_number(m.group(1), m.group(2))
74
 
75
+ def _dexs_link_for_ca(ca: str) -> str:
76
+ """Universal Dexscreener link. Default ke Ethereum untuk EVM; Solana untuk base58; fallback /token."""
77
+ if CA_EVM_RE.fullmatch(ca):
78
+ return f"https://dexscreener.com/ethereum/{ca.lower()}"
79
+ if CA_SOL_RE.fullmatch(ca):
80
+ return f"https://dexscreener.com/solana/{ca}"
81
+ # Fallback β€” Dexscreener juga paham /token/<addr> untuk beberapa chain
82
+ return f"https://dexscreener.com/token/{ca}"
83
+
84
+ def _fmt_usd(x: float) -> str:
85
+ if x >= 1e9: return f"${x/1e9:.2f}B"
86
+ if x >= 1e6: return f"${x/1e6:.2f}M"
87
+ if x >= 1e3: return f"${x/1e3:.2f}K"
88
+ return f"${x:.2f}"
89
+
90
+ def _ago_str(dt: datetime) -> str:
91
+ s = int((datetime.now(timezone.utc) - dt).total_seconds())
92
+ m, s = divmod(s, 60)
93
+ return f"{m}m {s}s"
94
+
95
+ # ========================
96
+ # === DEX API (async) ===
97
+ # ========================
98
+ DEX_BASE = "https://api.dexscreener.com/latest/dex/tokens"
99
+
100
+ async def fetch_mcap_usd(session: aiohttp.ClientSession, ca: str) -> Optional[float]:
101
  """
102
+ Ambil marketCap dari Dexscreener. Jika tidak ada, fallback ke FDV.
103
+ Return USD float atau None.
104
  """
105
+ url = f"{DEX_BASE}/{ca}"
106
+ try:
107
+ async with session.get(url, timeout=15) as resp:
108
+ if resp.status != 200:
109
+ return None
110
+ data = await resp.json()
111
+ except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  return None
113
+
114
+ pairs = data.get("pairs") or []
115
+ if not pairs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  return None
 
 
 
 
 
117
 
118
+ # Ambil pair dengan liquidity/volume tertinggi (heuristik sederhana)
119
+ best = max(
120
+ pairs,
121
+ key=lambda p: (
122
+ (p.get("liquidity", {}) or {}).get("usd", 0)
123
+ + (p.get("volume", {}) or {}).get("h24", 0)
124
+ ),
125
+ )
126
+
127
+ mc = best.get("marketCap")
128
+ if mc is None:
129
+ mc = best.get("fdv")
130
+ if mc is None:
131
  return None
132
+ try:
133
+ return float(mc)
134
+ except Exception:
135
  return None
136
 
137
  # =========================
138
+ # === TRACKING STRUCTS ===
139
  # =========================
140
  @dataclass
141
  class TrackItem:
142
  ca: str
143
+ entry_mcap: float
144
+ started_at: datetime
145
+ source_msg_id: int # id pesan botsignal yg di-reply
146
+ posted: Dict[float, bool] = field(default_factory=dict) # milestone -> posted?
147
+ last_announce_id: Optional[int] = None
148
+
149
+ def ready_milestones(self, current_mcap: float) -> List[float]:
150
+ """Milestone yang belum pernah dipost dan sudah terlewati."""
151
+ if not self.entry_mcap or self.entry_mcap <= 0:
152
+ return []
153
+ mult = current_mcap / self.entry_mcap
154
+ return [m for m in MILESTONES if mult >= m and not self.posted.get(m, False)]
155
+
156
+ def drop_hit(self, current_mcap: float) -> bool:
157
+ if DROP_DELETE_RATIO <= 0:
158
+ return False
159
+ return current_mcap <= self.entry_mcap * (1 - DROP_DELETE_RATIO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
+ # =========================
162
+ # === CORE AUTOTRACKER ===
163
+ # =========================
164
+ class AutoTracker:
165
+ def __init__(self, client: TelegramClient):
166
+ self.client = client
167
+ self.source_chat = SOURCE_CHAT
168
+ self.announce_chat = ANNOUNCE_CHAT
169
+ self.session = None # aiohttp
170
+ self.by_ca: Dict[str, TrackItem] = {} # CA -> TrackItem
171
+ self.ca_to_msg: Dict[str, int] = {} # CA -> source message id (botsignal)
172
+
173
+ async def start(self):
174
+ self.session = aiohttp.ClientSession()
175
+ # Listener: tangkap pesan botsignal yang mengandung CA dan entry mcap
176
+ @self.client.on(events.NewMessage(chats=self.source_chat))
177
+ async def handler(ev):
178
+ text = (ev.raw_text or "").strip()
179
+
180
+ # Cari CA
181
+ ca = None
182
+ m = CA_LINE_RE.search(text)
183
+ if m:
184
+ ca = m.group(1).strip()
185
+
186
+ if not ca:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
+ # Normalisasi some pump.fun-style? biarkan apa adanya β€” Dexscreener menerima addr itu.
190
+ entry_mcap = _extract_first_mcap_usd(text) # ambil angka MCAP pertama yg muncul
 
 
 
 
191
 
192
+ # Kalau tidak ada mcap di pesan, jangan arm dulu β€” tunggu pesan update lain
193
+ if not entry_mcap:
194
+ return
195
 
196
+ # Simpan mapping CA -> source message id
197
+ self.ca_to_msg[ca] = ev.message.id
198
+
199
+ # Arm jika belum ada
200
+ if ca not in self.by_ca:
201
+ self.by_ca[ca] = TrackItem(
202
+ ca=ca,
203
+ entry_mcap=entry_mcap,
204
+ started_at=datetime.now(timezone.utc),
205
+ source_msg_id=ev.message.id,
206
+ )
207
+ # Optional: kasih jejak kecil di log (tidak kirim ke chat)
208
+ print(f"[ARMED] {ca} at {entry_mcap:.2f} USD (msg {ev.message.id})")
209
+
210
+ # Loop polling harga
211
+ asyncio.create_task(self._poll_loop())
212
+
213
+ async def _poll_loop(self):
214
+ await self.client.connect()
215
  while True:
216
+ try:
217
+ await self._tick()
218
+ except Exception as e:
219
+ print("[POLL_ERR]", e)
220
+ await asyncio.sleep(POLL_SEC)
221
+
222
+ async def _tick(self):
223
+ if not self.by_ca:
224
+ return
225
+ async with aiohttp.ClientSession() as sess:
226
+ for ca, item in list(self.by_ca.items()):
227
+ current = await fetch_mcap_usd(sess, ca)
228
+ if not current:
 
229
  continue
230
+
231
+ # Auto-delete bila drop
232
+ if item.drop_hit(current):
233
+ await self._handle_drop_delete(item, current)
234
+ # Setelah hapus, hentikan tracking CA ini
235
+ self.by_ca.pop(ca, None)
236
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
+ # Milestones tercapai?
239
+ ready = item.ready_milestones(current)
240
+ if not ready:
241
+ continue
242
 
243
+ # Post untuk setiap milestone yang terlewati (urut kecil -> besar)
244
+ ready.sort()
245
+ for m in ready:
246
+ await self._post_milestone(item, m, current)
247
+ item.posted[m] = True
 
 
 
 
 
 
248
 
249
+ async def _handle_drop_delete(self, item: TrackItem, current_mcap: float):
250
+ """Opsional hapus thread ketika drawdown tembus ambang."""
251
+ try:
252
+ # Hapus pesan milestone terakhir (jika ada)
253
+ if item.last_announce_id:
254
+ await self.client.delete_messages(self.announce_chat, item.last_announce_id)
255
+ # Hapus pesan sumber botsignal
256
+ await self.client.delete_messages(self.source_chat, item.source_msg_id)
257
+ print(f"[DROP-DELETE] {item.ca} @ {_fmt_usd(current_mcap)} (entry {_fmt_usd(item.entry_mcap)})")
258
+ except Exception as e:
259
+ print("[DROP-DELETE ERR]", e)
260
+
261
+ def _milestone_text(self, item: TrackItem, milestone: float, current_mcap: float) -> str:
262
+ dexs_link = _dexs_link_for_ca(item.ca)
263
+ badge = self._badge_for(milestone)
264
+ change_line = f"{_fmt_usd(item.entry_mcap)} β†’ {_fmt_usd(current_mcap)} (~{current_mcap/item.entry_mcap:.2f}Γ—)"
265
+ axiom_line = f"πŸ›’ [Trade on Axiom]({AXIOM_REF})" if AXIOM_REF else ""
266
+ elapsed = _ago_str(item.started_at)
267
+
268
+ lines = [
269
+ "πŸ’Ž [MidasTouch Signal] πŸ’Ž",
270
+ f"{badge}",
271
+ f"{change_line}",
272
+ f"⏱️ {elapsed} since call",
273
+ f"πŸ”Ž [Dexscreener]({dexs_link})",
274
+ ]
275
+ if axiom_line:
276
+ lines.append(axiom_line)
277
+ lines.append(f"CA: `{item.ca}`")
278
+
279
+ return "\n".join(lines)
280
+
281
+ def _badge_for(self, m: float) -> str:
282
+ if m >= 10:
283
+ return f"πŸš€ **Spectacular Milestone {m:g}Γ—**"
284
+ if m >= 5:
285
+ return f"πŸš€ **Milestone {m:g}Γ—**"
286
+ if m >= 3:
287
+ return f"πŸ”₯ Milestone {m:g}Γ—"
288
+ if m >= 2:
289
+ return f"⚑ Milestone {m:g}Γ—"
290
+ return f"🎯 Milestone {m:g}Γ—"
291
+
292
+ async def _post_milestone(self, item: TrackItem, milestone: float, current_mcap: float):
293
+ text = self._milestone_text(item, milestone, current_mcap)
294
+ try:
295
+ msg = await self.client.send_message(
296
+ self.announce_chat,
297
+ f"{text}\n\n{BOT_MARKER}",
298
+ reply_to=item.source_msg_id,
299
+ link_preview=LINK_PREVIEW, # Penting: preview ON agar link Dexs klik-able & terlihat
300
+ )
301
+ item.last_announce_id = msg.id
302
+ print(f"[MILESTONE] {item.ca} hit {milestone}Γ— β†’ posted (msg {msg.id})")
303
+ except Exception as e:
304
+ print("[POST_ERR]", e)
305
 
306
  # =========================
307
+ # === BOOTSTRAP / MAIN ===
308
  # =========================
309
+ def main():
310
+ if not API_ID or not API_HASH:
311
+ raise SystemExit("Set TELEGRAM_API_ID/TELEGRAM_API_HASH environment variables first.")
312
 
313
+ client = TelegramClient(SESSION, API_ID, API_HASH)
314
+ tracker = AutoTracker(client)
 
 
315
 
316
+ async def runner():
317
+ await tracker.start()
318
+ print("[BOOT] autotrack attached βœ“")
319
+ await client.run_until_disconnected()
320
 
321
+ with client:
322
+ client.loop.run_until_complete(runner())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
 
324
  if __name__ == "__main__":
325
+ main()