agus1111 commited on
Commit
bd7f8e7
·
verified ·
1 Parent(s): 9c4a323

Update autotrack.py

Browse files
Files changed (1) hide show
  1. autotrack.py +267 -96
autotrack.py CHANGED
@@ -1,19 +1,61 @@
1
  # autotrack.py
2
- # Auto-tracking milestone (mis. x2) dari ENTRY (price atau market cap) untuk sebuah CA.
3
- # Sumber data: Dexscreener (gratis, tanpa API key) + fallback Jupiter (Solana).
4
- # Dependensi: aiohttp
 
 
5
 
6
  import os
 
7
  import time
8
  import math
9
  import asyncio
10
  import aiohttp
11
  from dataclasses import dataclass, field
12
  from typing import Optional, Dict, List, Tuple
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
15
- JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # gratis untuk Solana
16
 
 
 
 
17
  def _fmt_money(x: Optional[float]) -> str:
18
  if x is None:
19
  return "?"
@@ -24,7 +66,6 @@ def _fmt_money(x: Optional[float]) -> str:
24
  def _fmt_big(x: Optional[float]) -> str:
25
  if x is None:
26
  return "?"
27
- # Format untuk market cap
28
  if x >= 1_000_000_000:
29
  return f"${x/1_000_000_000:.2f}B"
30
  if x >= 1_000_000:
@@ -33,15 +74,49 @@ def _fmt_big(x: Optional[float]) -> str:
33
  return f"${x/1_000:.2f}K"
34
  return f"${x:.0f}"
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @dataclass
37
  class TrackItem:
38
  ca: str
39
- basis: str = "mcap" # "mcap" (default) atau "price"
40
  entry_price: Optional[float] = None
41
  entry_mcap: Optional[float] = None
42
  symbol_hint: Optional[str] = None
43
  source_link: Optional[str] = None
44
- milestones: List[float] = field(default_factory=lambda: [2.0]) # contoh: [1.5, 2.0, 3.0]
45
  hit: Dict[float, bool] = field(default_factory=dict)
46
  poll_secs: int = 20
47
  stop_when_hit: bool = True
@@ -49,27 +124,17 @@ class TrackItem:
49
 
50
  class PriceTracker:
51
  """
52
- Cara pakai (contoh):
53
- tracker = PriceTracker(client, announce_chat="@MidasTouchSignall")
54
- await tracker.start(
55
- ca="GXKC9BCWCiYVeEpQ8cFoFHpgAeXTzX3YCFX4BnaZpump",
56
- basis="mcap", # atau "price"
57
- milestones=[2.0], # x2
58
- poll_secs=20,
59
- source_link=None, # opsional link ke pesan sumber
60
- symbol_hint="COIN", # opsional
61
- stop_when_hit=True
62
- )
63
  """
64
- def __init__(self, client, announce_chat):
65
  self.client = client
66
  self.announce_chat = announce_chat
67
  self._tasks: Dict[str, asyncio.Task] = {}
68
  self._items: Dict[str, TrackItem] = {}
69
 
70
- # =========================
71
- # HTTP helpers
72
- # =========================
73
  async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
74
  tout = aiohttp.ClientTimeout(total=timeout)
75
  async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
@@ -80,8 +145,8 @@ class PriceTracker:
80
 
81
  async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
82
  """
83
- Mengembalikan (priceUsd, symbol, marketCapUsd/FDV) dari pair 'terbaik' (likuiditas USD terbesar).
84
- Bisa saja price atau mcap None bila tidak tersedia.
85
  """
86
  data = await self._get(DEXSCREENER_TOKEN_URL + ca)
87
  if not data:
@@ -89,27 +154,21 @@ class PriceTracker:
89
  pairs = data.get("pairs") or []
90
  if not pairs:
91
  return None
92
-
93
- # Pilih pair dengan likuiditas USD terbesar (heuristik stabil)
94
  best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
95
  price = float(best.get("priceUsd")) if best.get("priceUsd") else None
96
  symbol = ((best.get("baseToken") or {}).get("symbol")) or None
97
 
98
  mcap = None
99
- # Dexscreener kadang expose 'fdv' &/atau 'marketCap' pada pair
100
  fdv = best.get("fdv")
101
  if isinstance(fdv, (int, float)) and fdv > 0:
102
  mcap = float(fdv)
103
  mc = best.get("marketCap")
104
  if isinstance(mc, (int, float)) and mc > 0:
105
  mcap = float(mc)
106
-
107
  return (price, symbol, mcap)
108
 
109
  async def _jupiter_price(self, ca: str) -> Optional[float]:
110
- """
111
- Fallback harga khusus Solana via Jupiter (gratis).
112
- """
113
  try:
114
  data = await self._get(JUPITER_URL + ca)
115
  if not data:
@@ -123,47 +182,71 @@ class PriceTracker:
123
  return None
124
 
125
  async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
126
- """
127
- Ambil snapshot {price, mcap, symbol_hint}.
128
- Urutan fallback: Dexscreener -> Jupiter (Solana only).
129
- """
130
  res = await self._dexscreener_price(ca)
131
  if res:
132
  price, sym, mcap = res
133
- # kalau price kosong, coba fallback Jupiter
134
  if price is None:
135
  jp = await self._jupiter_price(ca)
136
  price = jp if jp is not None else None
137
  return {"price": price, "symbol_hint": sym, "mcap": mcap}
138
-
139
- # kalau Dexscreener gagal total → coba Jupiter (harga saja)
140
  jp = await self._jupiter_price(ca)
141
  if jp is not None:
142
  return {"price": jp, "symbol_hint": None, "mcap": None}
143
-
144
  return None
145
 
146
- # =========================
147
- # Helpers UI
148
- # =========================
149
  def _name(self, item: TrackItem) -> str:
150
  s = item.symbol_hint or ""
151
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
152
 
153
  async def _say(self, text: str):
154
  try:
155
- await self.client.send_message(self.announce_chat, text, link_preview=False)
156
  except Exception as e:
157
  print(f"[TRACK] announce failed: {e}")
158
 
159
- # =========================
160
- # Core loop
161
- # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  async def _loop(self, item: TrackItem):
163
- # Inisialisasi entry dari snapshot
164
  snap = await self._get_snapshot(item.ca)
165
  if not snap:
166
- await self._say(f"⚠️ Gagal mulai tracking untuk `{item.ca}` (tidak dapat harga/mcap awal).")
167
  return
168
 
169
  if item.entry_price is None:
@@ -173,21 +256,12 @@ class PriceTracker:
173
  if not item.symbol_hint and snap.get("symbol_hint"):
174
  item.symbol_hint = snap.get("symbol_hint")
175
 
176
- # Jika basis=mcap tapi mcap awal tidak ada → fallback ke basis=price
177
  if item.basis == "mcap" and not item.entry_mcap:
178
  item.basis = "price"
179
 
180
  basis_label = "Market Cap" if item.basis == "mcap" else "Price"
181
- entry_disp = _fmt_big(item.entry_mcap) if item.basis == "mcap" else _fmt_money(item.entry_price)
182
-
183
- await self._say(
184
- f"🛰️ Tracking: **{self._name(item)}** | Basis: **{basis_label}**\n"
185
- f"Entry: {entry_disp} • Milestones: {', '.join([f'{m}×' for m in item.milestones])}"
186
- + (f"\n🔗 {item.source_link}" if item.source_link else "")
187
- + f"\nCA: `{item.ca}`"
188
- )
189
 
190
- # Loop polling
191
  while True:
192
  snap = await self._get_snapshot(item.ca)
193
  if not snap:
@@ -199,7 +273,7 @@ class PriceTracker:
199
  if not item.symbol_hint and snap.get("symbol_hint"):
200
  item.symbol_hint = snap.get("symbol_hint")
201
 
202
- # hitung rasio sesuai basis
203
  if item.basis == "mcap":
204
  if not (item.entry_mcap and cur_mcap):
205
  await asyncio.sleep(item.poll_secs)
@@ -211,43 +285,20 @@ class PriceTracker:
211
  continue
212
  ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
213
 
214
- # cek milestones
215
  for m in item.milestones:
216
  if item.hit.get(m):
217
  continue
218
  if ratio >= m:
219
  item.hit[m] = True
220
- elapsed = int(time.time() - item.started_at)
221
- mm, ss = divmod(elapsed, 60)
222
- if item.basis == "mcap":
223
- msg = (
224
- "✅ **AUTO-TRACK**\n"
225
- f"{self._name(item)} menembus **{m}×** mcap\n"
226
- f"{_fmt_big(item.entry_mcap)} → {_fmt_big(cur_mcap)} (~{ratio:.2f}×)\n"
227
- f"⏱️ {mm}m {ss}s sejak call"
228
- + (f"\n🔗 {item.source_link}" if item.source_link else "")
229
- + f"\nCA: `{item.ca}`"
230
- )
231
- else:
232
- msg = (
233
- "✅ **AUTO-TRACK**\n"
234
- f"{self._name(item)} menembus **{m}×** harga\n"
235
- f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)\n"
236
- f"⏱️ {mm}m {ss}s sejak call"
237
- + (f"\n🔗 {item.source_link}" if item.source_link else "")
238
- + f"\nCA: `{item.ca}`"
239
- )
240
- await self._say(msg)
241
-
242
  if item.stop_when_hit and m == max(item.milestones):
243
- await self._say(f"🛑 Tracking dihentikan untuk {self._name(item)} (milestone akhir tercapai).")
244
  return
245
 
246
  await asyncio.sleep(item.poll_secs)
247
 
248
- # =========================
249
- # Public API
250
- # =========================
251
  def is_tracking(self, ca: str) -> bool:
252
  t = self._tasks.get(ca)
253
  return bool(t) and not t.done()
@@ -259,7 +310,7 @@ class PriceTracker:
259
  basis: str = "mcap",
260
  entry_price: Optional[float] = None,
261
  entry_mcap: Optional[float] = None,
262
- milestones: List[float] = [2.0],
263
  poll_secs: int = 20,
264
  source_link: Optional[str] = None,
265
  symbol_hint: Optional[str] = None,
@@ -268,6 +319,9 @@ class PriceTracker:
268
  ca = ca.strip()
269
  if self.is_tracking(ca):
270
  return
 
 
 
271
  item = TrackItem(
272
  ca=ca,
273
  basis=basis.lower(),
@@ -279,15 +333,132 @@ class PriceTracker:
279
  symbol_hint=symbol_hint,
280
  stop_when_hit=stop_when_hit,
281
  )
282
- self._items[ca] = item
283
  self._tasks[ca] = asyncio.create_task(self._loop(item))
284
 
285
- def stop(self, ca: str):
286
- t = self._tasks.get(ca)
287
- if t and not t.done():
288
- t.cancel()
289
 
290
- def stop_all(self):
291
- for t in list(self._tasks.values()):
292
- if t and not t.done():
293
- t.cancel()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # autotrack.py
2
+ # Auto-track milestones (e.g., ) for any Contract Address (CA) mentioned in NEW messages
3
+ # from YOUR group only: https://t.me/MidasTouchsignalll
4
+ #
5
+ # Data: Dexscreener (free) + fallback Jupiter (Solana)
6
+ # Deps: telethon, aiohttp
7
 
8
  import os
9
+ import re
10
  import time
11
  import math
12
  import asyncio
13
  import aiohttp
14
  from dataclasses import dataclass, field
15
  from typing import Optional, Dict, List, Tuple
16
+ from datetime import datetime, timezone, timedelta
17
 
18
+ from telethon import TelegramClient, events
19
+ from telethon.sessions import StringSession, MemorySession
20
+ from telethon.errors.rpcerrorlist import FloodWaitError
21
+
22
+ # =========================
23
+ # ENV / CONFIG
24
+ # =========================
25
+ API_ID = int(os.environ.get("API_ID", "0"))
26
+ API_HASH = os.environ.get("API_HASH", "")
27
+ STRING_SESSION = os.environ.get("STRING_SESSION", "")
28
+
29
+ # Announce ke grup kamu (dipakai juga sebagai sumber pesan)
30
+ TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
31
+
32
+ # Polling interval harga
33
+ TRACK_POLL_SECS = int(os.environ.get("TRACK_POLL_SECS", "20"))
34
+
35
+ # Milestones "x" sebagai string koma, contoh: "1.5,2,3"
36
+ _ms_env = os.environ.get("MILESTONES", "2")
37
+ try:
38
+ DEFAULT_MILESTONES = sorted({float(x.strip()) for x in _ms_env.split(",") if x.strip()})
39
+ except Exception:
40
+ DEFAULT_MILESTONES = [2.0]
41
+
42
+ STOP_WHEN_HIT = True # stop setelah milestone terbesar tercapai
43
+
44
+ # Abaikan pesan lebih tua dari (startup - buffer)
45
+ BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
46
+
47
+ # Marker antirecursive: ditambahkan ke semua pengumuman bot
48
+ BOT_MARKER = "【MT-AUTOTRACK】"
49
+
50
+ # =========================
51
+ # HTTP endpoints
52
+ # =========================
53
  DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
54
+ JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
55
 
56
+ # =========================
57
+ # Helpers
58
+ # =========================
59
  def _fmt_money(x: Optional[float]) -> str:
60
  if x is None:
61
  return "?"
 
66
  def _fmt_big(x: Optional[float]) -> str:
67
  if x is None:
68
  return "?"
 
69
  if x >= 1_000_000_000:
70
  return f"${x/1_000_000_000:.2f}B"
71
  if x >= 1_000_000:
 
74
  return f"${x/1_000:.2f}K"
75
  return f"${x:.0f}"
76
 
77
+ # =========================
78
+ # CA extraction
79
+ # =========================
80
+ CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (approx)
81
+ CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM 0x...
82
+
83
+ CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
84
+
85
+ def extract_ca(text: str) -> Optional[str]:
86
+ """Return first found CA (EVM or Solana). Prefer 'CA: <addr>' label if present."""
87
+ if not text:
88
+ return None
89
+
90
+ # Prefer explicit "CA: ..."
91
+ m = CA_LABEL_RE.search(text)
92
+ if m:
93
+ cand = m.group(1)
94
+ if CA_EVM_RE.fullmatch(cand):
95
+ return cand.lower()
96
+ if CA_SOL_RE.fullmatch(cand):
97
+ return cand
98
+
99
+ # Else, scan for EVM then Sol
100
+ m2 = CA_EVM_RE.search(text)
101
+ if m2:
102
+ return m2.group(0).lower()
103
+ m3 = CA_SOL_RE.search(text)
104
+ if m3:
105
+ return m3.group(0)
106
+ return None
107
+
108
+ # =========================
109
+ # Tracker core
110
+ # =========================
111
  @dataclass
112
  class TrackItem:
113
  ca: str
114
+ basis: str = "mcap" # "mcap" (default) or "price"
115
  entry_price: Optional[float] = None
116
  entry_mcap: Optional[float] = None
117
  symbol_hint: Optional[str] = None
118
  source_link: Optional[str] = None
119
+ milestones: List[float] = field(default_factory=lambda: [2.0])
120
  hit: Dict[float, bool] = field(default_factory=dict)
121
  poll_secs: int = 20
122
  stop_when_hit: bool = True
 
124
 
125
  class PriceTracker:
126
  """
127
+ Usage:
128
+ tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
129
+ await tracker.start(ca=..., basis="mcap", milestones=[2], poll_secs=20, source_link=..., symbol_hint=...)
 
 
 
 
 
 
 
 
130
  """
131
+ def __init__(self, client: TelegramClient, announce_chat):
132
  self.client = client
133
  self.announce_chat = announce_chat
134
  self._tasks: Dict[str, asyncio.Task] = {}
135
  self._items: Dict[str, TrackItem] = {}
136
 
137
+ # ------------- HTTP helpers -------------
 
 
138
  async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
139
  tout = aiohttp.ClientTimeout(total=timeout)
140
  async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
 
145
 
146
  async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
147
  """
148
+ Return (priceUsd, symbol, marketCapUsd/FDV) from 'best pair' by highest USD liquidity.
149
+ price or mcap may be None if unavailable.
150
  """
151
  data = await self._get(DEXSCREENER_TOKEN_URL + ca)
152
  if not data:
 
154
  pairs = data.get("pairs") or []
155
  if not pairs:
156
  return None
 
 
157
  best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
158
  price = float(best.get("priceUsd")) if best.get("priceUsd") else None
159
  symbol = ((best.get("baseToken") or {}).get("symbol")) or None
160
 
161
  mcap = None
 
162
  fdv = best.get("fdv")
163
  if isinstance(fdv, (int, float)) and fdv > 0:
164
  mcap = float(fdv)
165
  mc = best.get("marketCap")
166
  if isinstance(mc, (int, float)) and mc > 0:
167
  mcap = float(mc)
 
168
  return (price, symbol, mcap)
169
 
170
  async def _jupiter_price(self, ca: str) -> Optional[float]:
171
+ """Fallback for Solana via Jupiter."""
 
 
172
  try:
173
  data = await self._get(JUPITER_URL + ca)
174
  if not data:
 
182
  return None
183
 
184
  async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
185
+ """Return dict {price, mcap, symbol_hint} from Dexscreener then Jupiter fallback."""
 
 
 
186
  res = await self._dexscreener_price(ca)
187
  if res:
188
  price, sym, mcap = res
 
189
  if price is None:
190
  jp = await self._jupiter_price(ca)
191
  price = jp if jp is not None else None
192
  return {"price": price, "symbol_hint": sym, "mcap": mcap}
 
 
193
  jp = await self._jupiter_price(ca)
194
  if jp is not None:
195
  return {"price": jp, "symbol_hint": None, "mcap": None}
 
196
  return None
197
 
198
+ # ------------- Announce helpers (brand EN) -------------
 
 
199
  def _name(self, item: TrackItem) -> str:
200
  s = item.symbol_hint or ""
201
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
202
 
203
  async def _say(self, text: str):
204
  try:
205
+ await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", link_preview=False)
206
  except Exception as e:
207
  print(f"[TRACK] announce failed: {e}")
208
 
209
+ def _start_text(self, item: TrackItem, basis_label: str) -> str:
210
+ entry_disp = _fmt_big(item.entry_mcap) if item.basis == "mcap" else _fmt_money(item.entry_price)
211
+ return (
212
+ "💎 [MidasTouch Signal] 💎\n"
213
+ "New contract detected:\n\n"
214
+ f"CA: `{item.ca}`\n\n"
215
+ f"Tracking Basis: **{basis_label}**\n"
216
+ f"Entry: {entry_disp} • Milestones: {', '.join([f'{m}×' for m in item.milestones])}\n"
217
+ + (f"🔗 {item.source_link}\n" if item.source_link else "")
218
+ + "\nRemember: This is a signal, not financial advice.\n"
219
+ "Stay safe, stay golden ✨"
220
+ )
221
+
222
+ def _milestone_text(self, item: TrackItem, m: float, ratio: float, cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
223
+ if item.basis == "mcap":
224
+ change = f"{_fmt_big(item.entry_mcap)} → {_fmt_big(cur_mcap)} (~{ratio:.2f}×)"
225
+ milestone_line = f"Milestone reached: **{m}× Market Cap**"
226
+ else:
227
+ change = f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)"
228
+ milestone_line = f"Milestone reached: **{m}× Price**"
229
+
230
+ elapsed = int(time.time() - item.started_at)
231
+ mm, ss = divmod(elapsed, 60)
232
+ return (
233
+ "💎 [MidasTouch Signal] 💎\n"
234
+ f"{milestone_line}\n"
235
+ f"{self._name(item)}\n"
236
+ f"{change}\n"
237
+ f"⏱️ {mm}m {ss}s since call\n"
238
+ + (f"🔗 {item.source_link}\n" if item.source_link else "")
239
+ + f"CA: `{item.ca}`\n\n"
240
+ "Remember: This is a signal, not financial advice.\n"
241
+ "Stay safe, stay golden ✨"
242
+ )
243
+
244
+ # ------------- Loop -------------
245
  async def _loop(self, item: TrackItem):
246
+ # init snapshot
247
  snap = await self._get_snapshot(item.ca)
248
  if not snap:
249
+ await self._say(f"⚠️ Failed to start tracking for `{item.ca}` (no initial price/mcap).")
250
  return
251
 
252
  if item.entry_price is None:
 
256
  if not item.symbol_hint and snap.get("symbol_hint"):
257
  item.symbol_hint = snap.get("symbol_hint")
258
 
 
259
  if item.basis == "mcap" and not item.entry_mcap:
260
  item.basis = "price"
261
 
262
  basis_label = "Market Cap" if item.basis == "mcap" else "Price"
263
+ await self._say(self._start_text(item, basis_label))
 
 
 
 
 
 
 
264
 
 
265
  while True:
266
  snap = await self._get_snapshot(item.ca)
267
  if not snap:
 
273
  if not item.symbol_hint and snap.get("symbol_hint"):
274
  item.symbol_hint = snap.get("symbol_hint")
275
 
276
+ # ratio
277
  if item.basis == "mcap":
278
  if not (item.entry_mcap and cur_mcap):
279
  await asyncio.sleep(item.poll_secs)
 
285
  continue
286
  ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
287
 
288
+ # milestones
289
  for m in item.milestones:
290
  if item.hit.get(m):
291
  continue
292
  if ratio >= m:
293
  item.hit[m] = True
294
+ await self._say(self._milestone_text(item, m, ratio, cur_price, cur_mcap))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  if item.stop_when_hit and m == max(item.milestones):
296
+ await self._say(f"🛑 Tracking stopped for {self._name(item)} (final milestone reached).")
297
  return
298
 
299
  await asyncio.sleep(item.poll_secs)
300
 
301
+ # ------------- Public -------------
 
 
302
  def is_tracking(self, ca: str) -> bool:
303
  t = self._tasks.get(ca)
304
  return bool(t) and not t.done()
 
310
  basis: str = "mcap",
311
  entry_price: Optional[float] = None,
312
  entry_mcap: Optional[float] = None,
313
+ milestones: List[float] = None,
314
  poll_secs: int = 20,
315
  source_link: Optional[str] = None,
316
  symbol_hint: Optional[str] = None,
 
319
  ca = ca.strip()
320
  if self.is_tracking(ca):
321
  return
322
+ if milestones is None or not milestones:
323
+ milestones = DEFAULT_MILESTONES
324
+
325
  item = TrackItem(
326
  ca=ca,
327
  basis=basis.lower(),
 
333
  symbol_hint=symbol_hint,
334
  stop_when_hit=stop_when_hit,
335
  )
 
336
  self._tasks[ca] = asyncio.create_task(self._loop(item))
337
 
 
 
 
 
338
 
339
+
340
+ # =========================
341
+ # Setup: attach to existing client from botsignal
342
+ # =========================
343
+ def setup_autotrack(shared_client: TelegramClient, announce_chat: str | None = None):
344
+ """
345
+ Attach autotrack to an existing Telethon client.
346
+ - Registers the new-message handler for TARGET_CHAT
347
+ - Initializes global tracker with the shared client
348
+ """
349
+ global client, tracker, startup_time_utc
350
+ client = shared_client
351
+ if announce_chat:
352
+ ac = announce_chat
353
+ else:
354
+ ac = TARGET_CHAT
355
+ # initialize tracker with shared client
356
+ tracker = PriceTracker(client, announce_chat=ac)
357
+ # startup timestamp for anti-backfill
358
+ startup_time_utc = datetime.now(timezone.utc)
359
+ # register handler
360
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
361
+ print("[AUTOTRACK] attached to shared client; listening on", TARGET_CHAT)
362
+
363
+ # =========================
364
+ # Client bootstrap
365
+ # =========================
366
+ def build_client() -> TelegramClient:
367
+ if STRING_SESSION:
368
+ print(">> Using StringSession (persistent).")
369
+ return TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH)
370
+ print(">> Using MemorySession (login each run).")
371
+ return TelegramClient(MemorySession(), API_ID, API_HASH)
372
+
373
+ client: TelegramClient | None = None
374
+ startup_time_utc = None
375
+ me_user_id: Optional[int] = None # filled at start
376
+
377
+ tracker: PriceTracker | None = None
378
+
379
+ # =========================
380
+ # Event handler: ONLY your group
381
+ # =========================
382
+ CUTOFF_BUFFER = timedelta(minutes=BACKFILL_BUFFER_MINUTES)
383
+
384
+ def _is_old_message(msg_dt: Optional[datetime]) -> bool:
385
+ if not isinstance(msg_dt, datetime):
386
+ return False
387
+ # Telethon new-message handler biasanya sudah hanya message baru.
388
+ # Buffer ini sebagai pengaman tambahan.
389
+ return msg_dt.replace(tzinfo=timezone.utc) < (startup_time_utc - CUTOFF_BUFFER)
390
+
391
+ def _is_bot_own_message(event) -> bool:
392
+ # Jangan proses pesan yang dikirim oleh diri sendiri (script ini)
393
+ if getattr(event, "out", False):
394
+ return True
395
+ txt = (event.raw_text or "") if hasattr(event, "raw_text") else ""
396
+ return BOT_MARKER in (txt or "")
397
+
398
+ async def on_new_message(event):
399
+ try:
400
+ # anti-loop: jangan proses pesan sendiri/marker
401
+ if _is_bot_own_message(event):
402
+ return
403
+
404
+ msg = event.message
405
+ if _is_old_message(getattr(msg, "date", None)):
406
+ # ignore any old message near startup (extra safety)
407
+ return
408
+
409
+ text = msg.message or (getattr(msg, "raw_text", None) or "")
410
+ ca = extract_ca(text)
411
+ if not ca:
412
+ return # no CA found
413
+
414
+ # Optional: link back to source message
415
+ source_link = None
416
+ try:
417
+ # If chat has a public username, build t.me link
418
+ chat = await event.get_chat()
419
+ uname = getattr(chat, "username", None)
420
+ mid = getattr(msg, "id", None)
421
+ if uname and mid:
422
+ source_link = f"https://t.me/{uname}/{mid}"
423
+ except:
424
+ pass
425
+
426
+ # Start tracking
427
+ await tracker.start(
428
+ ca=ca,
429
+ basis="mcap",
430
+ milestones=DEFAULT_MILESTONES,
431
+ poll_secs=TRACK_POLL_SECS,
432
+ source_link=source_link,
433
+ stop_when_hit=STOP_WHEN_HIT,
434
+ )
435
+ # (Optional) send small ack? Usually start_text already covers announce.
436
+ # Here we rely on _start_text inside tracker._loop
437
+ except Exception as e:
438
+ print(f"[AUTOTRACK] error: {e}")
439
+
440
+ # =========================
441
+ # Entrypoint
442
+ # =========================
443
+
444
+ async def main():
445
+ global client, tracker, startup_time_utc
446
+ if client is None:
447
+ client = build_client()
448
+ startup_time_utc = datetime.now(timezone.utc)
449
+ tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
450
+ await client.start()
451
+ try:
452
+ me = await client.get_me()
453
+ globals()["me_user_id"] = int(getattr(me, "id", 0))
454
+ print(f">> Logged in as: {getattr(me, 'username', None) or me_user_id}")
455
+ except Exception as e:
456
+ print(f"Warning: cannot resolve self id: {e}")
457
+
458
+ print("AutoTrack running. Listening ONLY your group for NEW messages...")
459
+ # ensure handler is attached in standalone mode
460
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
461
+ await client.run_until_disconnected()
462
+
463
+ if __name__ == "__main__":
464
+ asyncio.run(main())