agus1111 commited on
Commit
110bc97
·
verified ·
1 Parent(s): bca4247

Update autotrack.py

Browse files
Files changed (1) hide show
  1. autotrack.py +401 -347
autotrack.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import re
3
  import time
@@ -5,10 +6,11 @@ import asyncio
5
  import aiohttp
6
  import sqlite3
7
  from dataclasses import dataclass, field
8
- from typing import Optional, Dict, Tuple, List, Sequence
 
9
 
10
  from telethon import TelegramClient, events
11
- from telethon.sessions import StringSession
12
 
13
  # =========================
14
  # ENV / CONFIG
@@ -27,75 +29,139 @@ TRACK_POLL_SECS = int(os.environ.get("TRACK_POLL_SECS", "20"))
27
  REPLY_FROM_MULTIPLE = float(os.environ.get("REPLY_FROM_MULTIPLE", "1.5"))
28
 
29
  # Unlimited mode: selalu lanjut 2×, 3×, 4×, ...
30
- UNLIMITED_MODE = True
 
31
 
32
- # Optional: kirim video mulai dari rasio ini
33
- VIDEO_MIN_MULTIPLE = float(os.environ.get("VIDEO_MIN_MULTIPLE", "3"))
 
34
 
35
- # Marker khusus biar gampang difilter
 
 
 
36
  BOT_MARKER = "【MT-AUTOTRACK】"
37
 
38
- # Dexscreener endpoint (Solana; ganti jika chain lain)
39
- DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
 
 
 
 
 
 
 
40
 
41
- # Axiom referal (CTA)
42
- AXIOM_REF = os.environ.get("AXIOM_REF", "https://axiom.trade/@1144321")
 
 
 
43
 
44
  # =========================
45
- # DB (opsional, untuk menyimpan origin message id)
46
  # =========================
47
- DB_PATH = os.environ.get("AUTOTRACK_DB", "/tmp/autotrack.db")
 
48
 
49
- def _db():
50
- conn = sqlite3.connect(DB_PATH)
51
- conn.execute(
52
- "CREATE TABLE IF NOT EXISTS origin (ca TEXT PRIMARY KEY, origin_msg_id INTEGER)"
53
- )
54
- return conn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- def save_origin_msg_id(ca_key: str, msg_id: int):
57
- try:
58
- with _db() as c:
59
- c.execute(
60
- "INSERT OR REPLACE INTO origin (ca, origin_msg_id) VALUES (?,?)",
61
- (ca_key, msg_id),
62
- )
63
- except Exception as e:
64
- print(f"[DB] save_origin_msg_id failed: {e}")
65
 
66
- def lookup_origin_msg_id(ca_key: str) -> Optional[int]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  try:
68
- with _db() as c:
69
- cur = c.execute("SELECT origin_msg_id FROM origin WHERE ca=?", (ca_key,))
70
- row = cur.fetchone()
71
- return int(row[0]) if row and row[0] is not None else None
 
 
 
 
72
  except Exception as e:
73
- print(f"[DB] lookup_origin_msg_id failed: {e}")
74
  return None
75
 
76
- def ca_key_for_db(ca: str) -> str:
77
- return ca.lower().strip()
78
-
79
  # =========================
80
- # Dataclass item track
81
  # =========================
82
  @dataclass
83
  class TrackItem:
84
  ca: str
85
- symbol_hint: Optional[str] = None # jika kamu parsing symbol dari pesan
86
- basis: str = "mcap" # "mcap" atau "price"
87
- entry_mcap: Optional[float] = None
88
  entry_price: Optional[float] = None
89
- origin_msg_id: Optional[int] = None
90
- started_at: int = field(default_factory=lambda: int(time.time()))
91
- # milestone yang sudah tercapai
92
- milestones_hit: List[float] = field(default_factory=list)
93
- # untuk opsi cleanup/edit (tidak dipakai default)
94
- last_reply_msg_id: Optional[int] = None
95
 
96
- # =========================
97
- # PriceTracker
98
- # =========================
99
  class PriceTracker:
100
  def __init__(self, client: TelegramClient, announce_chat):
101
  self.client = client
@@ -119,356 +185,344 @@ class PriceTracker:
119
  pairs = data.get("pairs") or []
120
  if not pairs:
121
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- # ambil pair terbaik (likuiditas/vol atau rank Dexscreener)
124
- # fallback sederhana: ambil yang punya mcap
125
- best = max(pairs, key=lambda p: float(p.get("fdv", 0) or 0.0))
126
- price_str = best.get("priceUsd")
127
- price = float(price_str) if price_str else None
128
- mcap = float(best.get("fdv")) if best.get("fdv") is not None else None
129
- symbol = best.get("baseToken", {}).get("symbol")
130
- return price, symbol, mcap
131
-
132
- # -------- Format helpers (tetap sederhana & aman Markdown) --------
133
- def _fmt_money(self, v: Optional[float]) -> str:
134
- if v is None:
135
- return "-"
136
- if v >= 1:
137
- return f"${v:,.2f}"
138
- return f"${v:.8f}".rstrip("0").rstrip(".")
139
-
140
- def _fmt_big(self, v: Optional[float]) -> str:
141
- if v is None:
142
- return "-"
143
- if v >= 1_000_000_000:
144
- return f"${v/1_000_000_000:.2f}B"
145
- if v >= 1_000_000:
146
- return f"${v/1_000_000:.2f}M"
147
- if v >= 1_000:
148
- return f"${v/1_000:.2f}K"
149
- return f"${v:.2f}"
150
 
 
151
  def _name(self, item: TrackItem) -> str:
152
  s = item.symbol_hint or ""
153
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
154
 
155
- # ======= (Tambahan untuk MEMPERCANTIK output — tidak mengubah flow) =======
156
- def _progress_bar(self, ratio_hit: float, milestone: float, width: int = 12) -> str:
157
- """Render progress 0..milestone (▮/▯)."""
158
- try:
159
- pct = max(0.0, min(1.0, (ratio_hit / max(milestone, 1e-9))))
160
- except Exception:
161
- pct = 0.0
162
- filled = int(round(pct * width))
163
- return "▮" * filled + "▯" * (width - filled)
164
-
165
- def _headline_and_spice(self, m: float) -> tuple[str, str]:
166
- """Headline & bumbu copywriting berdasar milestone."""
167
- if m < 2:
168
- return ("✨ **MidasTouch Milestone**", "Momentum ignited.")
169
- elif m < 3:
170
- return ("⚡ **MidasTouch Breakout**", "Pressure building—eyes on next leg.")
171
- elif m < 5:
172
- return ("🚀 **MidasTouch Liftoff**", "To the moon vibes.")
173
- else:
174
- return ("🌕 **MidasTouch Moon Mission**", "Price discovery—no ceilings.")
175
-
176
- def _badge(self, ratio: float) -> str:
177
- """Lencana rasa (ringan) berdasar rasio."""
178
- if ratio >= 8:
179
- return "FOMO 🔥"
180
- elif ratio >= 5:
181
- return "Strong 💪"
182
- elif ratio >= 3:
183
- return "Medium ⚡"
184
- else:
185
- return "Low 🌱"
186
-
187
- # -------- Kirim reply ke origin msg --------
188
- async def _send_reply_to_origin(self, ca: str, text: str, file_list: Optional[List[str]] = None):
189
  try:
190
  key = ca_key_for_db(ca)
191
  reply_to_id = lookup_origin_msg_id(key) if key else None
192
  if reply_to_id:
193
- await self.client.send_message(
194
- self.announce_chat, f"{text}\n\n{BOT_MARKER}", reply_to=reply_to_id,
195
- link_preview=False, file=file_list or None
196
- )
197
  else:
198
- await self.client.send_message(
199
- self.announce_chat, f"{text}\n\n{BOT_MARKER}",
200
- link_preview=False, file=file_list or None
201
- )
202
  except Exception as e:
203
  print(f"[TRACK] reply failed: {e}")
204
 
205
- # -------- Text milestone (DIPERCANTIK tanpa ubah flow lain) --------
206
  def _milestone_text(self, item: TrackItem, m: float, ratio: float,
207
- cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
208
- # Links (Solana style)
209
  dexs_link = f"https://dexscreener.com/solana/{item.ca}"
210
- axiom_link = AXIOM_REF
 
211
 
212
- # Headline & badge
213
- headline, spice = self._headline_and_spice(m)
214
- badge = self._badge(ratio)
215
-
216
- # Perubahan nilai
217
  if item.basis == "mcap":
218
- change = f"{self._fmt_big(item.entry_mcap)} → {self._fmt_big(cur_mcap)} (~{ratio:.2f}×)"
219
- basis_line = f"**{m}× Market Cap** ✅"
220
  else:
221
- change = f"{self._fmt_money(item.entry_price)} → {self._fmt_money(cur_price)} (~{ratio:.2f}×)"
222
- basis_line = f"**{m}× Price** ✅"
223
 
224
- # Nama token
225
- name_line = f"**{self._name(item)}**"
226
 
227
- # Waktu sejak call
228
  elapsed = int(time.time() - item.started_at)
229
- mm, ss = divmod(elapsed, 60)
230
- t_since = f"{mm}m {ss:02d}s since call"
231
-
232
- # Progress bar pada skala milestone yang baru tercapai
233
- bar = self._progress_bar(ratio_hit=ratio, milestone=m, width=12)
234
-
235
- # Next targets (biar audience kebayang step berikutnya)
236
- nxt1 = 2.0 if m < 2.0 else m + 1.0
237
- nxt2 = nxt1 + 1.0
238
- nxt_line = f"Next targets → **{nxt1:.0f}×** • **{nxt2:.0f}×**"
239
 
 
240
  lines = [
241
- headline,
242
- f"{basis_line} • **{badge}**",
243
  "",
244
- name_line,
245
- change,
246
- t_since,
247
  "",
248
- f"{bar} `{ratio:.2f}×`",
249
- nxt_line,
250
- "",
251
- "Quick links:",
252
- f"• 🔎 [Dexscreener]({dexs_link})",
253
- f"• 🛒 [Trade on Axiom]({axiom_link})",
254
  "",
255
  f"CA: `{item.ca}`",
256
  "",
257
- f"_{spice} Signals ≠ certainty. DYOR & manage risk like a pro._",
258
  ]
259
  return "\n".join(lines)
260
 
261
- # -------- Ratio & milestone logic --------
262
- def _ratio(self, item: TrackItem, price: Optional[float], mcap: Optional[float]) -> Optional[float]:
263
- if item.basis == "mcap":
264
- if not (item.entry_mcap and mcap) or item.entry_mcap <= 0:
265
- return None
266
- return mcap / item.entry_mcap
267
- else:
268
- if not (item.entry_price and price) or item.entry_price <= 0:
269
- return None
270
- return price / item.entry_price
271
 
272
- def arm(self, ca: str, origin_msg_id: Optional[int], basis: str,
273
- entry_price: Optional[float], entry_mcap: Optional[float],
274
- symbol_hint: Optional[str] = None):
275
- it = TrackItem(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  ca=ca,
277
- basis=basis,
278
  entry_price=entry_price,
279
  entry_mcap=entry_mcap,
280
- origin_msg_id=origin_msg_id,
 
281
  symbol_hint=symbol_hint,
 
282
  )
283
- self._items[ca] = it
284
- if origin_msg_id:
285
- save_origin_msg_id(ca_key_for_db(ca), origin_msg_id)
286
- print(f"[TRACK] armed {ca} basis={basis} entry_price={entry_price} entry_mcap={entry_mcap}")
287
-
288
- def stop(self, ca: str):
289
- t = self._tasks.pop(ca, None)
290
- if t:
291
- t.cancel()
292
- self._items.pop(ca, None)
293
- print(f"[TRACK] stopped {ca}")
294
-
295
- async def _poll_loop(self, ca: str):
296
- item = self._items.get(ca)
297
- if not item:
298
- return
299
- # milestone seeds
300
- milestones: List[float] = []
301
- seed = max(REPLY_FROM_MULTIPLE, 1.5)
302
- milestones.append(seed)
303
- milestones.append(2.0)
304
- milestones.append(3.0)
305
- milestones.append(5.0)
306
- milestones.append(8.0)
307
- milestones.append(10.0)
308
-
309
- # unlimited mode → lanjutkan kelipatan berikutnya dinamis
310
- max_m = max(milestones)
311
- try:
312
- while True:
313
- data = await self._dexscreener_price(ca)
314
- if not data:
315
- await asyncio.sleep(TRACK_POLL_SECS)
316
- continue
317
- price, symbol, mcap = data
318
- if symbol and not item.symbol_hint:
319
- item.symbol_hint = symbol
320
-
321
- r = self._ratio(item, price, mcap)
322
- if r and r >= 1.0:
323
- # milestone baru
324
- new_hits: List[float] = []
325
- for m in milestones:
326
- if m in item.milestones_hit:
327
- continue
328
- if r >= m:
329
- new_hits.append(m)
330
- if new_hits:
331
- m = max(new_hits)
332
- item.milestones_hit.extend(new_hits)
333
- text = self._milestone_text(item, m, r, price, mcap)
334
-
335
- # attach video jika memenuhi
336
- files: Optional[List[str]] = None
337
- if r >= VIDEO_MIN_MULTIPLE:
338
- files = self._video_for_ratio(r)
339
-
340
- await self._send_reply_to_origin(ca, text, file_list=files)
341
-
342
- # tambahkan milestone lanjutan bila unlimited
343
- if UNLIMITED_MODE and m >= max_m:
344
- # strategi: tambah +1 ke depannya
345
- for k in range(1, 4):
346
- milestones.append(m + k)
347
- max_m = max(milestones)
348
-
349
- await asyncio.sleep(TRACK_POLL_SECS)
350
- except asyncio.CancelledError:
351
- return
352
- except Exception as e:
353
- print(f"[TRACK] loop error for {ca}: {e}")
354
 
355
- def _video_for_ratio(self, ratio: float) -> Optional[List[str]]:
356
- # Mapping sederhana; ganti path sesuai aset kamu
357
- # (Kalau tidak punya, return None saja)
358
- try:
359
- if ratio >= 10:
360
- return ["/app/assets/vid_x10.mp4"]
361
- if ratio >= 5:
362
- return ["/app/assets/vid_x5.mp4"]
363
- if ratio >= 3:
364
- return ["/app/assets/vid_x3.mp4"]
365
- except Exception:
366
- pass
367
- return None
368
-
369
- # Public: mulai tracking satu CA
370
- def start(self, ca: str):
371
- if ca in self._tasks:
372
- return
373
- t = asyncio.create_task(self._poll_loop(ca))
374
- self._tasks[ca] = t
375
 
376
  # =========================
377
- # Parsers & handlers
378
  # =========================
379
-
380
- CA_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,44}\b") # kira-kira Solana base58 panjang 32..44
381
-
382
- def extract_ca(text: str) -> Optional[str]:
383
- m = CA_RE.search(text or "")
384
- return m.group(0) if m else None
385
-
386
- def extract_symbol(text: str) -> Optional[str]:
387
- # coba cari uppercase 3-8 huruf di dekat CA / judul
388
- m = re.search(r"\b([A-Z][A-Z0-9]{2,7})\b", text or "")
389
- return m.group(1) if m else None
390
 
391
  # =========================
392
- # Main bot
393
  # =========================
394
- async def main():
395
- if not (API_ID and API_HASH and STRING_SESSION):
396
- print("Please set API_ID, API_HASH, STRING_SESSION env.")
397
- return
398
-
399
- client = TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH)
400
- await client.connect()
401
-
402
- tracker = PriceTracker(client, TARGET_CHAT)
 
 
403
 
404
- @client.on(events.NewMessage(chats=(TARGET_CHAT,)))
405
- async def on_new_message(evt):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  try:
407
- if not evt.message or not evt.message.message:
 
 
 
408
  return
409
- # deteksi CA dari pesan baru (tanpa backfill)
410
- text = evt.message.message
411
- ca = extract_ca(text)
412
- if not ca:
413
- return
414
- # jika sudah armed, skip
415
- if ca in tracker._items:
416
- return
417
-
418
- # seed symbol & entry price/mcap
419
- data = await tracker._dexscreener_price(ca)
420
- if not data:
421
- return
422
- price, symbol, mcap = data
423
-
424
- # pilih basis default mcap jika ada, else price
425
- basis = "mcap" if mcap else "price"
426
-
427
- tracker.arm(
428
- ca=ca,
429
- origin_msg_id=evt.message.id,
430
- basis=basis,
431
- entry_price=price,
432
- entry_mcap=mcap,
433
- symbol_hint=symbol,
434
- )
435
-
436
- # start polling loop
437
- tracker.start(ca)
438
 
439
- # Bila sudah > 1.5× langsung reply (opsional)—di-flow ini kita biarkan loop yang menembakkan
440
- except Exception as e:
441
- print(f"[EVENT] on_new_message error: {e}")
442
 
443
- # Dengerin juga edit (tanpa backfill) misal ada CA yang ditambahkan saat edit
444
- @client.on(events.MessageEdited(chats=(TARGET_CHAT,)))
445
- async def on_msg_edited(evt):
446
  try:
447
- if not evt.message or not evt.message.message:
448
- return
449
- text = evt.message.message
450
- ca = extract_ca(text)
451
- if not ca or ca in tracker._items:
452
- return
453
- data = await tracker._dexscreener_price(ca)
454
- if not data:
455
- return
456
- price, symbol, mcap = data
457
- basis = "mcap" if mcap else "price"
458
-
459
- tracker.arm(
460
- ca=ca,
461
- origin_msg_id=evt.message.id,
462
- basis=basis,
463
- entry_price=price,
464
- entry_mcap=mcap,
465
- symbol_hint=symbol,
466
- )
467
- tracker.start(ca)
468
- except Exception as e:
469
- print(f"[EVENT] on_msg_edited error: {e}")
470
 
471
- print("AutoTrack running (unlimited milestones; first reply at ≥1.5×). Listening ONLY your group for NEW/EDITED messages...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  await client.run_until_disconnected()
473
 
474
  if __name__ == "__main__":
 
1
+ # autotrack.py — Unlimited milestones + auto-delete on drawdown + video ≥3×
2
  import os
3
  import re
4
  import time
 
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
 
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
+ CUTOFF_BUFFER = timedelta(minutes=BACKFILL_BUFFER_MINUTES)
38
 
39
+ # NEW: matikan cutoff default (biar pesan yang dipost sedikit sebelum attach tetap diproses).
40
+ DISABLE_OLD_MESSAGE_CUTOFF = os.environ.get("DISABLE_OLD_MESSAGE_CUTOFF", "1") == "1"
41
+
42
+ # Marker antirecursive: ditambahkan ke semua pengumuman bot (milestone)
43
  BOT_MARKER = "【MT-AUTOTRACK】"
44
 
45
+ # Lokasi DB milik botsignal (untuk ambil msg_id pesan awal)
46
+ BOTSIGNAL_DB = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db")
47
+
48
+ # NEW: Auto-delete bila drawdown >= threshold (rasio relatif terhadap entry)
49
+ # Contoh: 0.5 berarti -50%. Set 0 untuk mematikan.
50
+ DROP_DELETE_RATIO = float(os.environ.get("DROP_DELETE_RATIO", "0.5"))
51
+
52
+ # (opsional) juga hapus thread balasan bot yang bertanda BOT_MARKER
53
+ DELETE_THREAD_REPLIES = os.environ.get("DELETE_THREAD_REPLIES", "0") == "1"
54
 
55
+ # >>> VIDEO milestone ≥ 3×
56
+ # Nama file video di root repo (sefolder dengan autotrack.py). Ubah via env bila perlu.
57
+ MILESTONE_VIDEO = os.environ.get("MILESTONE_VIDEO", "Generating_To_The_Moon_Animation.mp4")
58
+ VIDEO_PATH = os.path.join(os.path.dirname(__file__), MILESTONE_VIDEO)
59
+ VIDEO_MIN_MULTIPLE = float(os.environ.get("VIDEO_MIN_MULTIPLE", "3.0")) # mulai kirim video dari x berapa
60
 
61
  # =========================
62
+ # HTTP endpoints
63
  # =========================
64
+ DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
65
+ JUPITER_URL = "https://price.jup.ag/v6/price?ids=" # free (Solana only)
66
 
67
+ # =========================
68
+ # Helpers
69
+ # =========================
70
+ def _fmt_money(x: Optional[float]) -> str:
71
+ if x is None:
72
+ return "?"
73
+ if x >= 1:
74
+ return f"${x:,.4f}"
75
+ return f"${x:.8f}"
76
+
77
+ def _fmt_big(x: Optional[float]) -> str:
78
+ if x is None:
79
+ return "?"
80
+ if x >= 1_000_000_000:
81
+ return f"${x/1_000_000_000:.2f}B"
82
+ if x >= 1_000_000:
83
+ return f"${x/1_000_000:.2f}M"
84
+ if x >= 1_000:
85
+ return f"${x/1_000:.2f}K"
86
+ return f"${x:.0f}"
87
+
88
+ def _parse_target_username(url_or_username: str) -> Optional[str]:
89
+ """Terima 'https://t.me/xxx' atau '@xxx' atau 'xxx' → kembalikan 'xxx' (lower)."""
90
+ if not url_or_username:
91
+ return None
92
+ s = url_or_username.strip()
93
+ if s.startswith("https://t.me/"):
94
+ s = s.split("https://t.me/", 1)[1]
95
+ if s.startswith("@"):
96
+ s = s[1:]
97
+ return s.lower()
98
 
99
+ # =========================
100
+ # CA extraction
101
+ # =========================
102
+ CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (approx)
103
+ CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM 0x...
104
+
105
+ CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*(\S+)", re.IGNORECASE)
 
 
106
 
107
+ def extract_ca(text: str) -> Optional[str]:
108
+ """Return first found CA (EVM or Solana). Prefer 'CA: <addr>' label if present."""
109
+ if not text:
110
+ return None
111
+ m = CA_LABEL_RE.search(text)
112
+ if m:
113
+ cand = m.group(1)
114
+ if CA_EVM_RE.fullmatch(cand):
115
+ return cand.lower()
116
+ if CA_SOL_RE.fullmatch(cand):
117
+ return cand
118
+ m2 = CA_EVM_RE.search(text or "")
119
+ if m2:
120
+ return m2.group(0).lower()
121
+ m3 = CA_SOL_RE.search(text or "")
122
+ if m3:
123
+ return m3.group(0)
124
+ return None
125
+
126
+ def ca_key_for_db(ca: str) -> Optional[str]:
127
+ if not ca:
128
+ return None
129
+ if CA_EVM_RE.fullmatch(ca):
130
+ return f"ca:evm:{ca.lower()}"
131
+ if CA_SOL_RE.fullmatch(ca):
132
+ return f"ca:sol:{ca}"
133
+ return None
134
+
135
+ def lookup_origin_msg_id(keyword: str) -> Optional[int]:
136
+ """Ambil msg_id pertama dari DB botsignal untuk keyword entitas CA."""
137
  try:
138
+ conn = sqlite3.connect(BOTSIGNAL_DB)
139
+ cur = conn.cursor()
140
+ cur.execute("SELECT msg_id FROM last_posted WHERE keyword = ?", (keyword,))
141
+ row = cur.fetchone()
142
+ conn.close()
143
+ if row and isinstance(row[0], int):
144
+ return row[0]
145
+ return None
146
  except Exception as e:
147
+ print(f"[TRACK][DB] lookup error: {e}")
148
  return None
149
 
 
 
 
150
  # =========================
151
+ # Tracker core (UNLIMITED)
152
  # =========================
153
  @dataclass
154
  class TrackItem:
155
  ca: str
156
+ basis: str = "mcap" # "mcap" (default) or "price"
 
 
157
  entry_price: Optional[float] = None
158
+ entry_mcap: Optional[float] = None
159
+ symbol_hint: Optional[str] = None
160
+ source_link: Optional[str] = None
161
+ poll_secs: int = 20
162
+ next_milestone: float = field(default_factory=lambda: REPLY_FROM_MULTIPLE)
163
+ started_at: float = field(default_factory=time.time)
164
 
 
 
 
165
  class PriceTracker:
166
  def __init__(self, client: TelegramClient, announce_chat):
167
  self.client = client
 
185
  pairs = data.get("pairs") or []
186
  if not pairs:
187
  return None
188
+ best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
189
+ price = float(best.get("priceUsd")) if best.get("priceUsd") else None
190
+ symbol = ((best.get("baseToken") or {}).get("symbol")) or None
191
+
192
+ mcap = None
193
+ fdv = best.get("fdv")
194
+ if isinstance(fdv, (int, float)) and fdv > 0:
195
+ mcap = float(fdv)
196
+ mc = best.get("marketCap")
197
+ if isinstance(mc, (int, float)) and mc > 0:
198
+ mcap = float(mc)
199
+ return (price, symbol, mcap)
200
+
201
+ async def _jupiter_price(self, ca: str) -> Optional[float]:
202
+ try:
203
+ data = await self._get(JUPITER_URL + ca)
204
+ if not data:
205
+ return None
206
+ d = (data.get("data") or {}).get(ca)
207
+ if not d:
208
+ return None
209
+ p = d.get("price")
210
+ return float(p) if p is not None else None
211
+ except:
212
+ return None
213
 
214
+ async def _get_snapshot(self, ca: str) -> Optional[Dict[str, Optional[float]]]:
215
+ res = await self._dexscreener_price(ca)
216
+ if res:
217
+ price, sym, mcap = res
218
+ if price is None:
219
+ jp = await self._jupiter_price(ca)
220
+ price = jp if jp is not None else None
221
+ return {"price": price, "mcap": mcap, "symbol_hint": sym}
222
+ jp = await self._jupiter_price(ca)
223
+ if jp is not None:
224
+ return {"price": jp, "mcap": None, "symbol_hint": None}
225
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
+ # -------- Announce helpers --------
228
  def _name(self, item: TrackItem) -> str:
229
  s = item.symbol_hint or ""
230
  return f"{s} ({item.ca[:4]}…{item.ca[-4:]})" if s else item.ca
231
 
232
+ async def _send_reply_to_origin(self, ca: str, text: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  try:
234
  key = ca_key_for_db(ca)
235
  reply_to_id = lookup_origin_msg_id(key) if key else None
236
  if reply_to_id:
237
+ await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", reply_to=reply_to_id, link_preview=False)
 
 
 
238
  else:
239
+ await self.client.send_message(self.announce_chat, f"{text}\n\n{BOT_MARKER}", link_preview=False)
 
 
 
240
  except Exception as e:
241
  print(f"[TRACK] reply failed: {e}")
242
 
 
243
  def _milestone_text(self, item: TrackItem, m: float, ratio: float,
244
+ cur_price: Optional[float], cur_mcap: Optional[float]) -> str:
245
+ # Links & label basis
246
  dexs_link = f"https://dexscreener.com/solana/{item.ca}"
247
+ axiom_link = "https://axiom.trade/@1144321"
248
+ basis_label = "Market Cap" if item.basis == "mcap" else "Price"
249
 
250
+ # Perubahan angka (jaga format sesuai basis)
 
 
 
 
251
  if item.basis == "mcap":
252
+ change = f"{_fmt_big(item.entry_mcap)} → {_fmt_big(cur_mcap)} (~{ratio:.2f}×)"
 
253
  else:
254
+ change = f"{_fmt_money(item.entry_price)} → {_fmt_money(cur_price)} (~{ratio:.2f}×)"
 
255
 
256
+ # Nama token (punya fallback yg sudah ada)
257
+ name = self._name(item)
258
 
259
+ # Elapsed time (hh:mm:ss)
260
  elapsed = int(time.time() - item.started_at)
261
+ hh, rem = divmod(elapsed, 3600)
262
+ mm, ss = divmod(rem, 60)
263
+ elapsed_str = (f"{hh}h {mm}m {ss}s" if hh else f"{mm}m {ss}s")
 
 
 
 
 
 
 
264
 
265
+ # Header + body yang rapi & branded
266
  lines = [
267
+ "💎 **MidasTouch Signal**",
268
+ f"**Milestone {m}× {basis_label}** • **Now:** ~{ratio:.2f}×",
269
  "",
270
+ f"**{name}**",
271
+ f"{change}",
272
+ f"⏱️ Since call: {elapsed_str}",
273
  "",
274
+ "Quick Actions:",
275
+ f"• 🔎 [View on Dexscreener]({dexs_link})",
276
+ f"• 🛒 [Trade with Axiom]({axiom_link})",
 
 
 
277
  "",
278
  f"CA: `{item.ca}`",
279
  "",
280
+ "_Signals ≠ certainty. DYOR. Manage risk like a pro._"
281
  ]
282
  return "\n".join(lines)
283
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ def _next_target_after(self, current_target: float) -> float:
286
+ # Setelah 1.5×, lanjut integer berikutnya: 2×, 3×, 4×, ...
287
+ return 2.0 if current_target < 2.0 else current_target + 1.0
288
+
289
+ async def _delete_origin_and_replies(self, ca: str):
290
+ """Hapus pesan origin di TARGET_CHAT (dan opsional balasan bot)."""
291
+ try:
292
+ key = ca_key_for_db(ca)
293
+ msg_id = lookup_origin_msg_id(key) if key else None
294
+ if not msg_id:
295
+ return
296
+ # Hapus pesan origin
297
+ await self.client.delete_messages(self.announce_chat, msg_id)
298
+ print(f"[TRACK] Deleted origin message for {ca}")
299
+
300
+ if DELETE_THREAD_REPLIES:
301
+ # Coba ambil beberapa pesan terakhir & hapus balasan bot yang menandai BOT_MARKER dan reply_to origin
302
+ try:
303
+ async for m in self.client.iter_messages(self.announce_chat, limit=100, reply_to=msg_id):
304
+ txt = (m.message or "") if hasattr(m, "message") else ""
305
+ if BOT_MARKER in (txt or ""):
306
+ await self.client.delete_messages(self.announce_chat, m.id)
307
+ print(f"[TRACK] Deleted thread replies for {ca}")
308
+ except Exception as e:
309
+ print(f"[TRACK] delete thread replies failed: {e}")
310
+ except Exception as e:
311
+ print(f"[TRACK] delete origin failed: {e}")
312
+
313
+ # -------- Loop --------
314
+ async def _loop(self, item: TrackItem):
315
+ snap = await self._get_snapshot(item.ca)
316
+ if not snap:
317
+ print(f"[TRACK] init snapshot failed for {item.ca}")
318
+ return
319
+
320
+ if item.entry_price is None:
321
+ item.entry_price = snap.get("price")
322
+ if item.entry_mcap is None:
323
+ item.entry_mcap = snap.get("mcap")
324
+ if not item.symbol_hint and snap.get("symbol_hint"):
325
+ item.symbol_hint = snap.get("symbol_hint")
326
+
327
+ if item.basis == "mcap" and not item.entry_mcap:
328
+ print(f"[TRACK] {item.ca} no mcap available at entry. Fallback to price basis.")
329
+ item.basis = "price"
330
+
331
+ print(f"[TRACK] start {item.ca} basis={item.basis} entry_mcap={item.entry_mcap} entry_price={item.entry_price} poll={item.poll_secs}s")
332
+
333
+ while True:
334
+ snap = await self._get_snapshot(item.ca)
335
+ if not snap:
336
+ await asyncio.sleep(item.poll_secs)
337
+ continue
338
+
339
+ cur_price = snap.get("price")
340
+ cur_mcap = snap.get("mcap")
341
+ if not item.symbol_hint and snap.get("symbol_hint"):
342
+ item.symbol_hint = snap.get("symbol_hint")
343
+
344
+ # Hitung rasio vs entry
345
+ if item.basis == "mcap":
346
+ if not (item.entry_mcap and cur_mcap):
347
+ await asyncio.sleep(item.poll_secs)
348
+ continue
349
+ ratio = (cur_mcap / item.entry_mcap) if item.entry_mcap > 0 else 0.0
350
+ else:
351
+ if not (item.entry_price and cur_price):
352
+ await asyncio.sleep(item.poll_secs)
353
+ continue
354
+ ratio = (cur_price / item.entry_price) if item.entry_price > 0 else 0.0
355
+
356
+ # === NEW: auto delete kalau drop >= threshold ===
357
+ if DROP_DELETE_RATIO > 0 and ratio <= DROP_DELETE_RATIO:
358
+ print(f"[TRACK] {item.ca} dropped to {ratio:.2f}× (≤ {DROP_DELETE_RATIO}×). Deleting origin & stopping.")
359
+ await self._delete_origin_and_replies(item.ca)
360
+ return # stop tracking setelah dihapus
361
+
362
+ # --- milestone handling ---
363
+ while ratio >= item.next_milestone:
364
+ print(f"[TRACK] {item.ca} hit {item.next_milestone}x (ratio={ratio:.2f})")
365
+ text = self._milestone_text(item, item.next_milestone, ratio, cur_price, cur_mcap)
366
+ await self._send_reply_to_origin(item.ca, text)
367
+
368
+ # >>> VIDEO milestone ≥ 3× (atau sesuai VIDEO_MIN_MULTIPLE)
369
+ if item.next_milestone >= VIDEO_MIN_MULTIPLE:
370
+ if os.path.isfile(VIDEO_PATH):
371
+ try:
372
+ key = ca_key_for_db(item.ca)
373
+ reply_to_id = lookup_origin_msg_id(key) if key else None
374
+ await self.client.send_file(
375
+ self.announce_chat,
376
+ VIDEO_PATH,
377
+ caption="🚀 To the Moon! 🌕",
378
+ reply_to=reply_to_id if reply_to_id else None,
379
+ force_document=False
380
+ )
381
+ except Exception as e:
382
+ print(f"[TRACK] gagal kirim video: {e}")
383
+ else:
384
+ print(f"[TRACK] VIDEO_PATH tidak ditemukan: {VIDEO_PATH}")
385
+
386
+ # Naikkan target ke milestone berikut
387
+ item.next_milestone = self._next_target_after(item.next_milestone)
388
+
389
+ await asyncio.sleep(item.poll_secs)
390
+
391
+ # -------- Public --------
392
+ def is_tracking(self, ca: str) -> bool:
393
+ t = self._tasks.get(ca)
394
+ return bool(t) and not t.done()
395
+
396
+ async def start(self, ca: str, *, basis: str = "mcap",
397
+ entry_price: Optional[float] = None,
398
+ entry_mcap: Optional[float] = None,
399
+ poll_secs: int = 20,
400
+ source_link: Optional[str] = None,
401
+ symbol_hint: Optional[str] = None):
402
+ ca = ca.strip()
403
+ if self.is_tracking(ca):
404
+ return
405
+ item = TrackItem(
406
  ca=ca,
407
+ basis=basis.lower(),
408
  entry_price=entry_price,
409
  entry_mcap=entry_mcap,
410
+ poll_secs=poll_secs,
411
+ source_link=source_link,
412
  symbol_hint=symbol_hint,
413
+ next_milestone=REPLY_FROM_MULTIPLE,
414
  )
415
+ print(f"[TRACK] arming {ca} (basis={item.basis}, poll={item.poll_secs}s) from handler")
416
+ self._tasks[ca] = asyncio.create_task(self._loop(item))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
  # =========================
420
+ # Setup: attach to existing client from botsignal
421
  # =========================
422
+ def setup_autotrack(shared_client: TelegramClient, announce_chat: str | None = None):
423
+ global client, tracker, startup_time_utc
424
+ client = shared_client
425
+ ac = announce_chat or TARGET_CHAT
426
+ tracker = PriceTracker(client, announce_chat=ac)
427
+ startup_time_utc = datetime.now(timezone.utc)
428
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
429
+ # NEW: dengarkan juga edit (tanpa backfill)
430
+ client.add_event_handler(on_new_message, events.MessageEdited(chats=(TARGET_CHAT,)))
431
+ print("[AUTOTRACK] attached to shared client; listening on", TARGET_CHAT)
 
432
 
433
  # =========================
434
+ # Client bootstrap
435
  # =========================
436
+ def build_client() -> TelegramClient:
437
+ if STRING_SESSION:
438
+ print(">> Using StringSession (persistent).")
439
+ return TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH)
440
+ print(">> Using MemorySession (login each run).")
441
+ return TelegramClient(MemorySession(), API_ID, API_HASH)
442
+
443
+ client: TelegramClient | None = None
444
+ startup_time_utc = None
445
+ me_user_id: Optional[int] = None
446
+ tracker: PriceTracker | None = None
447
 
448
+ # =========================
449
+ # Event handler: ONLY your group
450
+ # =========================
451
+ def _is_old_message(msg_dt: Optional[datetime]) -> bool:
452
+ if DISABLE_OLD_MESSAGE_CUTOFF:
453
+ return False
454
+ if not isinstance(msg_dt, datetime):
455
+ return False
456
+ return msg_dt.replace(tzinfo=timezone.utc) < (startup_time_utc - CUTOFF_BUFFER)
457
+
458
+ def _is_bot_own_message(event) -> bool:
459
+ txt = (event.raw_text or "") if hasattr(event, "raw_text") else ""
460
+ return BOT_MARKER in (txt or "")
461
+
462
+ async def on_new_message(event):
463
+ try:
464
+ # Handler-side filter: only process messages from TARGET_CHAT
465
  try:
466
+ target_user = _parse_target_username(TARGET_CHAT)
467
+ chat = await event.get_chat()
468
+ uname = (getattr(chat, 'username', None) or '')
469
+ if target_user and (uname.lower() != target_user):
470
  return
471
+ except Exception:
472
+ # If we cannot resolve chat username, continue (best-effort)
473
+ pass
474
+ if _is_bot_own_message(event):
475
+ return
476
+ msg = event.message
477
+ if _is_old_message(getattr(msg, "date", None)):
478
+ return
479
+ text = msg.message or (getattr(msg, "raw_text", None) or "")
480
+ ca = extract_ca(text)
481
+ if not ca:
482
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
 
484
+ # Log arming point for traceability
485
+ print(f"[TRACK] arming {ca} from message id={getattr(msg, 'id', None)} (edited={isinstance(event, events.MessageEdited.Event)})")
 
486
 
487
+ # Optional: link back to source (tidak dipakai di teks sekarang, tapi disimpan kalau perlu)
488
+ source_link = None
 
489
  try:
490
+ chat = await event.get_chat()
491
+ uname = getattr(chat, "username", None)
492
+ mid = getattr(msg, "id", None)
493
+ if uname and mid:
494
+ source_link = f"https://t.me/{uname}/{mid}"
495
+ except:
496
+ pass
497
+ await tracker.start(
498
+ ca=ca,
499
+ basis="mcap",
500
+ poll_secs=TRACK_POLL_SECS,
501
+ source_link=source_link,
502
+ )
503
+ except Exception as e:
504
+ print(f"[AUTOTRACK] error: {e}")
 
 
 
 
 
 
 
 
505
 
506
+ # =========================
507
+ # Entrypoint
508
+ # =========================
509
+ async def main():
510
+ global client, tracker, startup_time_utc
511
+ if client is None:
512
+ client = build_client()
513
+ startup_time_utc = datetime.now(timezone.utc)
514
+ tracker = PriceTracker(client, announce_chat=TARGET_CHAT)
515
+ await client.start()
516
+ try:
517
+ me = await client.get_me()
518
+ globals()["me_user_id"] = int(getattr(me, "id", 0))
519
+ print(f">> Logged in as: {getattr(me, 'username', None) or me_user_id}")
520
+ except Exception as e:
521
+ print(f"Warning: cannot resolve self id: {e}")
522
+ print(f"AutoTrack running (unlimited milestones; first reply ≥ {REPLY_FROM_MULTIPLE}×). Listening ONLY your group for NEW/EDITED messages...")
523
+ client.add_event_handler(on_new_message, events.NewMessage(chats=(TARGET_CHAT,)))
524
+ # NEW: dengarkan juga edit (tanpa backfill)
525
+ client.add_event_handler(on_new_message, events.MessageEdited(chats=(TARGET_CHAT,)))
526
  await client.run_until_disconnected()
527
 
528
  if __name__ == "__main__":