agus1111 commited on
Commit
34ba6be
·
verified ·
1 Parent(s): 8b84fd2

Update autotrack.py

Browse files
Files changed (1) hide show
  1. autotrack.py +196 -132
autotrack.py CHANGED
@@ -1,36 +1,54 @@
1
-
2
-
3
  import os
4
  import re
5
  import time
6
- import math
7
  import asyncio
8
  import aiohttp
 
9
  from dataclasses import dataclass, field
10
- from typing import Optional, Dict, List, Tuple
11
  from datetime import datetime, timezone, timedelta
12
 
13
- from telethon import events, TelegramClient
 
14
 
15
  # =========================
16
  # ENV / CONFIG
17
  # =========================
 
 
 
 
 
18
  TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
 
 
19
  TRACK_POLL_SECS = int(os.environ.get("TRACK_POLL_SECS", "20"))
20
 
21
- MIN_START_MULT = float(os.environ.get("MIN_START_MULT", "1.5")) # start dari x1.5
22
- STEP_MULT = float(os.environ.get("STEP_MULT", "0.5")) # naik 0.5 tiap hit
23
- USE_MULTIPLICATIVE_STEP = os.environ.get("USE_MULTIPLICATIVE_STEP", "0") == "1"
 
 
 
24
 
 
25
  BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
26
- BOT_MARKER = os.environ.get("BOT_MARKER", "【MT-AUTOTRACK】")
27
 
 
 
 
 
 
 
 
28
  # HTTP endpoints
 
29
  DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
30
  JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
31
 
32
  # =========================
33
- # Helpers (formatting)
34
  # =========================
35
  def _fmt_money(x: Optional[float]) -> str:
36
  if x is None:
@@ -55,6 +73,7 @@ def _fmt_big(x: Optional[float]) -> str:
55
  # =========================
56
  CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (approx)
57
  CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM 0x...
 
58
  CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
59
 
60
  def extract_ca(text: str) -> Optional[str]:
@@ -68,16 +87,40 @@ def extract_ca(text: str) -> Optional[str]:
68
  return cand.lower()
69
  if CA_SOL_RE.fullmatch(cand):
70
  return cand
71
- m2 = CA_EVM_RE.search(text)
72
  if m2:
73
  return m2.group(0).lower()
74
- m3 = CA_SOL_RE.search(text)
75
  if m3:
76
  return m3.group(0)
77
  return None
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # =========================
80
- # Tracker core (generator milestones)
81
  # =========================
82
  @dataclass
83
  class TrackItem:
@@ -87,24 +130,18 @@ class TrackItem:
87
  entry_mcap: Optional[float] = None
88
  symbol_hint: Optional[str] = None
89
  source_link: Optional[str] = None
90
- poll_secs: int = TRACK_POLL_SECS
91
- # generator state
92
- next_target: float = field(default_factory=lambda: max(1.5, MIN_START_MULT))
93
  started_at: float = field(default_factory=time.time)
94
 
95
  class PriceTracker:
96
- """
97
- Milestone tanpa batas.
98
- - next_target dimulai dari MIN_START_MULT (default 1.5).
99
- - Saat ratio >= next_target → announce, lalu next_target += STEP_MULT (atau *= (1+STEP) jika USE_MULTIPLICATIVE_STEP=1).
100
- """
101
  def __init__(self, client: TelegramClient, announce_chat):
102
  self.client = client
103
  self.announce_chat = announce_chat
104
  self._tasks: Dict[str, asyncio.Task] = {}
105
  self._items: Dict[str, TrackItem] = {}
106
 
107
- # ---------- HTTP ----------
108
  async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
109
  tout = aiohttp.ClientTimeout(total=timeout)
110
  async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
@@ -114,9 +151,6 @@ class PriceTracker:
114
  return await r.json()
115
 
116
  async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
117
- """
118
- Return (priceUsd, symbol, marketCapUsd/FDV) from 'best pair' by highest USD liquidity.
119
- """
120
  data = await self._get(DEXSCREENER_TOKEN_URL + ca)
121
  if not data:
122
  return None
@@ -137,7 +171,6 @@ class PriceTracker:
137
  return (price, symbol, mcap)
138
 
139
  async def _jupiter_price(self, ca: str) -> Optional[float]:
140
- """Fallback for Solana via Jupiter."""
141
  try:
142
  data = await self._get(JUPITER_URL + ca)
143
  if not data:
@@ -151,51 +184,44 @@ class PriceTracker:
151
  return None
152
 
153
  async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
154
- """Return dict {price, mcap, symbol_hint} from Dexscreener then Jupiter fallback."""
155
  res = await self._dexscreener_price(ca)
156
  if res:
157
  price, sym, mcap = res
158
  if price is None:
159
  jp = await self._jupiter_price(ca)
160
  price = jp if jp is not None else None
161
- return {"price": price, "symbol_hint": sym, "mcap": mcap}
162
  jp = await self._jupiter_price(ca)
163
  if jp is not None:
164
- return {"price": jp, "symbol_hint": None, "mcap": None}
165
  return None
166
 
167
- # ---------- Announce ----------
168
  def _name(self, item: TrackItem) -> str:
169
  s = item.symbol_hint or ""
170
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
171
 
172
- async def _say(self, text: str):
173
  try:
174
- await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", link_preview=False)
 
 
 
 
 
175
  except Exception as e:
176
- print(f"[TRACK] announce failed: {e}")
177
 
178
- def _start_text(self, item: TrackItem, basis_label: str) -> str:
179
- entry_disp = _fmt_big(item.entry_mcap) if item.basis == "mcap" else _fmt_money(item.entry_price)
180
- return (
181
- "💎 [MidasTouch Signal] 💎\n"
182
- "New contract detected:\n\n"
183
- f"CA: `{item.ca}`\n\n"
184
- f"Tracking Basis: **{basis_label}**\n"
185
- f"Entry: {entry_disp} • Next milestone: {item.next_target:.2f}×\n"
186
- + (f"🔗 {item.source_link}\n" if item.source_link else "")
187
- + "\nRemember: This is a signal, not financial advice.\n"
188
- "Stay safe, stay golden ✨"
189
- )
190
-
191
- def _milestone_text(self, item: TrackItem, hit_level: float, ratio: float, cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
192
  if item.basis == "mcap":
193
  change = f"{_fmt_big(item.entry_mcap)} → {_fmt_big(cur_mcap)} (~{ratio:.2f}×)"
194
- milestone_line = f"Milestone reached: **{hit_level:.2f}× Market Cap**"
195
  else:
196
  change = f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)"
197
- milestone_line = f"Milestone reached: **{hit_level:.2f}× Price**"
198
-
199
  elapsed = int(time.time() - item.started_at)
200
  mm, ss = divmod(elapsed, 60)
201
  return (
@@ -204,18 +230,20 @@ class PriceTracker:
204
  f"{self._name(item)}\n"
205
  f"{change}\n"
206
  f"⏱️ {mm}m {ss}s since call\n"
207
- + (f"🔗 {item.source_link}\n" if item.source_link else "")
208
- + f"CA: `{item.ca}`\n\n"
209
- "Remember: This is a signal, not financial advice.\n"
210
- "Stay safe, stay golden ✨"
211
  )
212
 
213
- # ---------- Loop ----------
 
 
 
 
214
  async def _loop(self, item: TrackItem):
215
- # init snapshot
216
  snap = await self._get_snapshot(item.ca)
217
  if not snap:
218
- await self._say(f"⚠️ Failed to start tracking for `{item.ca}` (no initial price/mcap).")
219
  return
220
 
221
  if item.entry_price is None:
@@ -225,16 +253,9 @@ class PriceTracker:
225
  if not item.symbol_hint and snap.get("symbol_hint"):
226
  item.symbol_hint = snap.get("symbol_hint")
227
 
228
- # Jika basis mcap tapi tidak ada mcap → fallback ke price
229
  if item.basis == "mcap" and not item.entry_mcap:
230
  item.basis = "price"
231
 
232
- # Pastikan next_target minimal MIN_START_MULT dan > 1.0
233
- item.next_target = max(MIN_START_MULT, 1.0001)
234
-
235
- basis_label = "Market Cap" if item.basis == "mcap" else "Price"
236
- await self._say(self._start_text(item, basis_label))
237
-
238
  while True:
239
  snap = await self._get_snapshot(item.ca)
240
  if not snap:
@@ -246,7 +267,6 @@ class PriceTracker:
246
  if not item.symbol_hint and snap.get("symbol_hint"):
247
  item.symbol_hint = snap.get("symbol_hint")
248
 
249
- # ratio
250
  if item.basis == "mcap":
251
  if not (item.entry_mcap and cur_mcap):
252
  await asyncio.sleep(item.poll_secs)
@@ -258,88 +278,132 @@ class PriceTracker:
258
  continue
259
  ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
260
 
261
- # --- GENERATOR TANPA BATAS ---
262
- # Kirim semua milestone yang terlewati (kalau harga "loncat")
263
- # contoh: next_target 2.0 tapi ratio 2.7 → kirim 2.0 dan 2.5
264
- hits_this_poll = 0
265
- while ratio >= item.next_target:
266
- # Anti spam: batasi hit per poll (optional)
267
- if hits_this_poll >= 4:
268
- break
269
- hit_level = item.next_target
270
- await self._say(self._milestone_text(item, hit_level, ratio, cur_price, cur_mcap))
271
- # next target
272
- if USE_MULTIPLICATIVE_STEP:
273
- # multiplicative: misal STEP_MULT=0.5 => next *= 1.5
274
- item.next_target = round(item.next_target * (1.0 + STEP_MULT), 6)
275
- else:
276
- # additive: next += 0.5
277
- item.next_target = round(item.next_target + STEP_MULT, 6)
278
- hits_this_poll += 1
279
 
280
  await asyncio.sleep(item.poll_secs)
281
 
282
- # ---------- Public ----------
283
  def is_tracking(self, ca: str) -> bool:
284
- return ca in self._tasks and not self._tasks[ca].done()
285
-
286
- async def start(self, ca: str, basis: str = "mcap", source_link: Optional[str] = None, symbol_hint: Optional[str] = None, poll_secs: Optional[int] = None):
 
 
 
 
 
 
 
287
  if self.is_tracking(ca):
288
  return
289
- item = TrackItem(ca=ca, basis=basis, source_link=source_link, symbol_hint=symbol_hint)
290
- if poll_secs:
291
- item.poll_secs = poll_secs
292
- task = asyncio.create_task(self._loop(item))
293
- self._tasks[ca] = task
 
 
 
 
 
 
 
294
 
295
  # =========================
296
- # Wiring ke Telethon: setup_autotrack(client)
297
  # =========================
298
- def setup_autotrack(client: TelegramClient):
299
- """
300
- Pasang handler yang:
301
- - Mendengar pesan BARU dari TARGET_CHAT kamu.
302
- - Ekstrak CA dari teks.
303
- - Mulai tracking otomatis bila belum berjalan.
304
- - Skip pesan yang berasal dari bot sendiri (ada BOT_MARKER).
305
- """
306
- tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
307
- started_at = datetime.now(timezone.utc)
308
 
309
- @client.on(events.NewMessage(chats=TARGET_CHAT))
310
- async def _on_new_message(ev: events.NewMessage.Event):
311
- msg = ev.message
312
- # Hindari recursive: jika pesan berisi marker bot, abaikan
313
- if msg.raw_text and BOT_MARKER in msg.raw_text:
314
- return
 
 
 
 
 
 
 
 
315
 
316
- # Hindari backfill pesan lama di awal start
317
- if msg.date:
318
- if msg.date.tzinfo is None:
319
- msg_dt = msg.date.replace(tzinfo=timezone.utc)
320
- else:
321
- msg_dt = msg.date.astimezone(timezone.utc)
322
- if msg_dt < (started_at - timedelta(minutes=BACKFILL_BUFFER_MINUTES)):
323
- return
 
 
 
 
 
324
 
325
- text = (msg.raw_text or "").strip()
 
 
 
 
 
 
 
326
  ca = extract_ca(text)
327
  if not ca:
328
  return
329
-
330
- # Link sumber (opsional)
331
- src_link = None
332
  try:
333
- src_link = await client.get_messages(TARGET_CHAT, ids=msg.id)
334
- if getattr(src_link, "id", None) is not None:
335
- # gunakan t.me link standar (public group disarankan)
336
- src_link = f"https://t.me/{ev.chat.username}/{msg.id}" if getattr(ev.chat, "username", None) else None
 
337
  except:
338
- src_link = None
339
-
340
- # Start tracking bila belum
341
- if not tracker.is_tracking(ca):
342
- # default basis mcap; fallback ke price terjadi di _loop jika mcap tidak tersedia
343
- await tracker.start(ca=ca, basis="mcap", source_link=src_link)
 
 
 
344
 
345
- return tracker # optional: bisa dipakai kalau mau manual query status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # autotrack.py — Unlimited milestones: 1.5× then 2×, 3×, 4×, ...
 
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
+ # =========================
45
  # HTTP endpoints
46
+ # =========================
47
  DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
48
  JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
49
 
50
  # =========================
51
+ # Helpers
52
  # =========================
53
  def _fmt_money(x: Optional[float]) -> str:
54
  if x is None:
 
73
  # =========================
74
  CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (approx)
75
  CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM 0x...
76
+
77
  CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
78
 
79
  def extract_ca(text: str) -> Optional[str]:
 
87
  return cand.lower()
88
  if CA_SOL_RE.fullmatch(cand):
89
  return cand
90
+ m2 = CA_EVM_RE.search(text or "")
91
  if m2:
92
  return m2.group(0).lower()
93
+ m3 = CA_SOL_RE.search(text or "")
94
  if m3:
95
  return m3.group(0)
96
  return None
97
 
98
+ def ca_key_for_db(ca: str) -> Optional[str]:
99
+ if not ca:
100
+ return None
101
+ if CA_EVM_RE.fullmatch(ca):
102
+ return f"ca:evm:{ca.lower()}"
103
+ if CA_SOL_RE.fullmatch(ca):
104
+ return f"ca:sol:{ca}"
105
+ return None
106
+
107
+ def lookup_origin_msg_id(keyword: str) -> Optional[int]:
108
+ """Ambil msg_id pertama dari DB botsignal untuk keyword entitas CA."""
109
+ try:
110
+ conn = sqlite3.connect(BOTSIGNAL_DB)
111
+ cur = conn.cursor()
112
+ cur.execute("SELECT msg_id FROM last_posted WHERE keyword = ?", (keyword,))
113
+ row = cur.fetchone()
114
+ conn.close()
115
+ if row and isinstance(row[0], int):
116
+ return row[0]
117
+ return None
118
+ except Exception as e:
119
+ print(f"[TRACK][DB] lookup error: {e}")
120
+ return None
121
+
122
  # =========================
123
+ # Tracker core (UNLIMITED)
124
  # =========================
125
  @dataclass
126
  class TrackItem:
 
130
  entry_mcap: Optional[float] = None
131
  symbol_hint: Optional[str] = None
132
  source_link: Optional[str] = None
133
+ poll_secs: int = 20
134
+ next_milestone: float = field(default_factory=lambda: REPLY_FROM_MULTIPLE)
 
135
  started_at: float = field(default_factory=time.time)
136
 
137
  class PriceTracker:
 
 
 
 
 
138
  def __init__(self, client: TelegramClient, announce_chat):
139
  self.client = client
140
  self.announce_chat = announce_chat
141
  self._tasks: Dict[str, asyncio.Task] = {}
142
  self._items: Dict[str, TrackItem] = {}
143
 
144
+ # -------- HTTP helpers --------
145
  async def _get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 8):
146
  tout = aiohttp.ClientTimeout(total=timeout)
147
  async with aiohttp.ClientSession(timeout=tout, headers=headers) as sess:
 
151
  return await r.json()
152
 
153
  async def _dexscreener_price(self, ca: str) -> Optional[Tuple[Optional[float], Optional[str], Optional[float]]]:
 
 
 
154
  data = await self._get(DEXSCREENER_TOKEN_URL + ca)
155
  if not data:
156
  return None
 
171
  return (price, symbol, mcap)
172
 
173
  async def _jupiter_price(self, ca: str) -> Optional[float]:
 
174
  try:
175
  data = await self._get(JUPITER_URL + ca)
176
  if not data:
 
184
  return None
185
 
186
  async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
 
187
  res = await self._dexscreener_price(ca)
188
  if res:
189
  price, sym, mcap = res
190
  if price is None:
191
  jp = await self._jupiter_price(ca)
192
  price = jp if jp is not None else None
193
+ return {"price": price, "mcap": mcap, "symbol_hint": sym}
194
  jp = await self._jupiter_price(ca)
195
  if jp is not None:
196
+ return {"price": jp, "mcap": None, "symbol_hint": None}
197
  return None
198
 
199
+ # -------- Announce helpers --------
200
  def _name(self, item: TrackItem) -> str:
201
  s = item.symbol_hint or ""
202
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
203
 
204
+ async def _send_reply_to_origin(self, ca: str, text: str):
205
  try:
206
+ key = ca_key_for_db(ca)
207
+ reply_to_id = lookup_origin_msg_id(key) if key else None
208
+ if reply_to_id:
209
+ await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", reply_to=reply_to_id, link_preview=False)
210
+ else:
211
+ await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", link_preview=False)
212
  except Exception as e:
213
+ print(f"[TRACK] reply failed: {e}")
214
 
215
+ def _milestone_text(self, item: TrackItem, m: float, ratio: float,
216
+ cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
217
+ dexs_link = f"https://dexscreener.com/token/{item.ca}"
218
+ axiom_link = "https://axiom.trade/@1144321"
 
 
 
 
 
 
 
 
 
 
219
  if item.basis == "mcap":
220
  change = f"{_fmt_big(item.entry_mcap)} → {_fmt_big(cur_mcap)} (~{ratio:.2f}×)"
221
+ milestone_line = f"Milestone reached: **{m}× Market Cap**"
222
  else:
223
  change = f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)"
224
+ milestone_line = f"Milestone reached: **{m}× Price**"
 
225
  elapsed = int(time.time() - item.started_at)
226
  mm, ss = divmod(elapsed, 60)
227
  return (
 
230
  f"{self._name(item)}\n"
231
  f"{change}\n"
232
  f"⏱️ {mm}m {ss}s since call\n"
233
+ f"🔎 [Dexscreener]({dexs_link})\n"
234
+ f"🛒 [Trade on Axiom]({axiom_link})\n"
235
+ f"CA: `{item.ca}`"
 
236
  )
237
 
238
+ def _next_target_after(self, current_target: float) -> float:
239
+ # Setelah 1.5×, lanjut integer berikutnya: 2×, 3×, 4×, ...
240
+ return 2.0 if current_target < 2.0 else current_target + 1.0
241
+
242
+ # -------- Loop --------
243
  async def _loop(self, item: TrackItem):
 
244
  snap = await self._get_snapshot(item.ca)
245
  if not snap:
246
+ print(f"[TRACK] init snapshot failed for {item.ca}")
247
  return
248
 
249
  if item.entry_price is None:
 
253
  if not item.symbol_hint and snap.get("symbol_hint"):
254
  item.symbol_hint = snap.get("symbol_hint")
255
 
 
256
  if item.basis == "mcap" and not item.entry_mcap:
257
  item.basis = "price"
258
 
 
 
 
 
 
 
259
  while True:
260
  snap = await self._get_snapshot(item.ca)
261
  if not snap:
 
267
  if not item.symbol_hint and snap.get("symbol_hint"):
268
  item.symbol_hint = snap.get("symbol_hint")
269
 
 
270
  if item.basis == "mcap":
271
  if not (item.entry_mcap and cur_mcap):
272
  await asyncio.sleep(item.poll_secs)
 
278
  continue
279
  ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
280
 
281
+ # Unlimited milestones
282
+ while ratio >= item.next_milestone:
283
+ text = self._milestone_text(item, item.next_milestone, ratio, cur_price, cur_mcap)
284
+ await self._send_reply_to_origin(item.ca, text)
285
+ # Naikkan target ke milestone berikut
286
+ item.next_milestone = self._next_target_after(item.next_milestone)
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  await asyncio.sleep(item.poll_secs)
289
 
290
+ # -------- Public --------
291
  def is_tracking(self, ca: str) -> bool:
292
+ t = self._tasks.get(ca)
293
+ return bool(t) and not t.done()
294
+
295
+ async def start(self, ca: str, *, basis: str = "mcap",
296
+ entry_price: Optional[float] = None,
297
+ entry_mcap: Optional[float] = None,
298
+ poll_secs: int = 20,
299
+ source_link: Optional[str] = None,
300
+ symbol_hint: Optional[str] = None):
301
+ ca = ca.strip()
302
  if self.is_tracking(ca):
303
  return
304
+ item = TrackItem(
305
+ ca=ca,
306
+ basis=basis.lower(),
307
+ entry_price=entry_price,
308
+ entry_mcap=entry_mcap,
309
+ poll_secs=poll_secs,
310
+ source_link=source_link,
311
+ symbol_hint=symbol_hint,
312
+ next_milestone=REPLY_FROM_MULTIPLE,
313
+ )
314
+ self._tasks[ca] = asyncio.create_task(self._loop(item))
315
+
316
 
317
  # =========================
318
+ # Setup: attach to existing client from botsignal
319
  # =========================
320
+ def setup_autotrack(shared_client: TelegramClient, announce_chat: str | None = None):
321
+ global client, tracker, startup_time_utc
322
+ client = shared_client
323
+ ac = announce_chat or TARGET_CHAT
324
+ tracker = PriceTracker(client, announce_chat=ac)
325
+ startup_time_utc = datetime.now(timezone.utc)
326
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
327
+ print("[AUTOTRACK] attached to shared client; listening on", TARGET_CHAT)
 
 
328
 
329
+ # =========================
330
+ # Client bootstrap
331
+ # =========================
332
+ def build_client() -> TelegramClient:
333
+ if STRING_SESSION:
334
+ print(">> Using StringSession (persistent).")
335
+ return TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH)
336
+ print(">> Using MemorySession (login each run).")
337
+ return TelegramClient(MemorySession(), API_ID, API_HASH)
338
+
339
+ client: TelegramClient | None = None
340
+ startup_time_utc = None
341
+ me_user_id: Optional[int] = None
342
+ tracker: PriceTracker | None = None
343
 
344
+ # =========================
345
+ # Event handler: ONLY your group
346
+ # =========================
347
+ CUTOFF_BUFFER = timedelta(minutes=BACKFILL_BUFFER_MINUTES)
348
+
349
+ def _is_old_message(msg_dt: Optional[datetime]) -> bool:
350
+ if not isinstance(msg_dt, datetime):
351
+ return False
352
+ return msg_dt.replace(tzinfo=timezone.utc) < (startup_time_utc - CUTOFF_BUFFER)
353
+
354
+ def _is_bot_own_message(event) -> bool:
355
+ txt = (event.raw_text or "") if hasattr(event, "raw_text") else ""
356
+ return BOT_MARKER in (txt or "")
357
 
358
+ async def on_new_message(event):
359
+ try:
360
+ if _is_bot_own_message(event):
361
+ return
362
+ msg = event.message
363
+ if _is_old_message(getattr(msg, "date", None)):
364
+ return
365
+ text = msg.message or (getattr(msg, "raw_text", None) or "")
366
  ca = extract_ca(text)
367
  if not ca:
368
  return
369
+ # Optional: link back to source (tidak dipakai di teks sekarang, tapi disimpan kalau perlu)
370
+ source_link = None
 
371
  try:
372
+ chat = await event.get_chat()
373
+ uname = getattr(chat, "username", None)
374
+ mid = getattr(msg, "id", None)
375
+ if uname and mid:
376
+ source_link = f"https://t.me/{uname}/{mid}"
377
  except:
378
+ pass
379
+ await tracker.start(
380
+ ca=ca,
381
+ basis="mcap",
382
+ poll_secs=TRACK_POLL_SECS,
383
+ source_link=source_link,
384
+ )
385
+ except Exception as e:
386
+ print(f"[AUTOTRACK] error: {e}")
387
 
388
+ # =========================
389
+ # Entrypoint
390
+ # =========================
391
+ async def main():
392
+ global client, tracker, startup_time_utc
393
+ if client is None:
394
+ client = build_client()
395
+ startup_time_utc = datetime.now(timezone.utc)
396
+ tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
397
+ await client.start()
398
+ try:
399
+ me = await client.get_me()
400
+ globals()["me_user_id"] = int(getattr(me, "id", 0))
401
+ print(f">> Logged in as: {getattr(me, 'username', None) or me_user_id}")
402
+ except Exception as e:
403
+ print(f"Warning: cannot resolve self id: {e}")
404
+ print(f"AutoTrack running (unlimited milestones; first reply ≥ {REPLY_FROM_MULTIPLE}×). Listening ONLY your group for NEW messages...")
405
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
406
+ await client.run_until_disconnected()
407
+
408
+ if __name__ == "__main__":
409
+ asyncio.run(main())