agus1111 commited on
Commit
7bd8c16
·
verified ·
1 Parent(s): 2c1544f

Update botsignal.py

Browse files
Files changed (1) hide show
  1. botsignal.py +282 -460
botsignal.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import asyncio
2
  import os
3
  import re
@@ -16,16 +18,99 @@ from telethon import TelegramClient, events
16
  from telethon.sessions import StringSession, MemorySession
17
  from telethon.errors.rpcerrorlist import FloodWaitError
18
 
19
- # Attach autotrack (silent start, reply >=1.5x) — pastikan autotrack.py kamu versi terbaru
20
- from autotrack import setup_autotrack
21
-
 
 
 
 
 
 
 
22
 
23
- # ========= Configuration via Environment =========
 
 
24
  API_ID = int(os.environ.get("API_ID", "0"))
25
- API_HASH = os.environ.get("API_HASH", "" )
26
  STRING_SESSION = os.environ.get("STRING_SESSION", "")
27
 
28
- # --- Sumber CORE vs SUPPORT ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  CORE_CHATS = [
30
  "https://t.me/PEPE_Calls28",
31
  "https://t.me/SephirothGemCalls1",
@@ -93,103 +178,9 @@ SUPPORT_CHATS = [
93
  ]
94
  SOURCE_CHATS = CORE_CHATS + SUPPORT_CHATS
95
 
96
- TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
97
-
98
- # ========== Leaderboard config ==========
99
- LEADERBOARD_GROUP = os.environ.get("LEADERBOARD_GROUP", "https://t.me/+IaefBrMZwW5kMTEx")
100
- LB_TRIGGER = os.environ.get("LB_TRIGGER", "/lb")
101
- LEADERBOARD_BOT = os.environ.get("LEADERBOARD_BOT", "@PhanesGreenBot")
102
- LB_REQUIRE_MIN_RANKS = int(os.environ.get("LB_REQUIRE_MIN_RANKS", "0"))
103
-
104
- # ====== Scheduler interval (acak per JAM) ======
105
- LB_INTERVAL_MIN_HOURS = int(os.environ.get("LB_INTERVAL_MIN_HOURS", "3"))
106
- LB_INTERVAL_MAX_HOURS = int(os.environ.get("LB_INTERVAL_MAX_HOURS", "20"))
107
-
108
- # ====== Anti-spam Leaderboard (cooldown + dedup) ======
109
- LEADERBOARD_COOLDOWN_SEC = int(os.environ.get("LEADERBOARD_COOLDOWN_SEC", "600"))
110
- _last_lb_hash: Optional[str] = None
111
- _last_lb_ts: Optional[float] = None
112
-
113
- # Tambahan: simpan ID bot Phanes untuk cek via_bot
114
- PHANES_BOT_ID: Optional[int] = None
115
- LB_TEXT_RE = re.compile(r"(🏆\s*Leaderboard|📊\s*Group\s*Stats)", re.IGNORECASE)
116
-
117
- # Zero-width char remover & normalizer untuk hash leaderboard
118
- ZERO_WIDTH_RE = re.compile(r"[\u200b\u200c\u200d\u2060\ufeff]")
119
-
120
- def _hash_text_1line(t: str) -> str:
121
- t1 = re.sub(r"\s+", " ", (t or "")).strip()
122
- return hashlib.sha1(t1.encode("utf-8", errors="ignore")).hexdigest()
123
-
124
- def _normalize_lb_for_hash(t: str) -> str:
125
- if not t:
126
- return ""
127
- t = ZERO_WIDTH_RE.sub("", t)
128
- # hilangkan angka volatile (views, harga, persen, menit)
129
- t = re.sub(r"\b\d+(\.\d+)?%?\b", "<n>", t)
130
- t = re.sub(r"\s+", " ", t).strip().lower()
131
- return t
132
-
133
- def _is_true_leaderboard(text: str) -> bool:
134
- if not text:
135
- return False
136
- if not re.search(r"🏆\s*Leaderboard", text):
137
- return False
138
- if not re.search(r"📊\s*Group\s*Stats", text):
139
- return False
140
- if LB_REQUIRE_MIN_RANKS > 0:
141
- ranks = re.findall(r"(?m)^\s*[\W\s]*\d{1,2}\s+.+\[[\d\.]+x\]\s*$", text)
142
- if len(ranks) < LB_REQUIRE_MIN_RANKS:
143
- return False
144
- return True
145
-
146
- # ====== Backfill & dedup ======
147
- INITIAL_BACKFILL = 2
148
- DEDUP_BUFFER_SIZE = int(os.environ.get("DEDUP_BUFFER_SIZE", "800"))
149
-
150
- # >>> Perpanjang window klasifikasi biar naik tier lebih lama <<<
151
- CLASS_WINDOW_MINUTES = int(os.environ.get("CLASS_WINDOW_MINUTES", "180"))
152
-
153
- # >>> Minimal unique support agar boleh lewat "gate lama" (jika belum ada core)
154
- SUPPORT_MIN_UNIQUE = int(os.environ.get("SUPPORT_MIN_UNIQUE", "2"))
155
-
156
- # >>> Low minimal call unik (baru boleh dipost saat unik >= nilai ini)
157
- LOW_MIN_UNIQUE = int(os.environ.get("LOW_MIN_UNIQUE", "2"))
158
-
159
- # DRY RUN (tidak kirim apa pun ke TARGET_CHAT)
160
- DRY_RUN = os.environ.get("DRY_RUN", "0") == "1"
161
-
162
- # Backfill buffer: abaikan pesan lebih tua dari (startup_time - buffer)
163
- BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
164
-
165
- # === Update behavior strategy ===
166
- # edit : edit pesan terakhir untuk entitas saat UPDATE
167
- # reply : balas (reply_to) pesan pertama entitas itu
168
- # new : kirim pesan baru untuk setiap UPDATE
169
- UPDATE_STRATEGY = os.environ.get("UPDATE_STRATEGY", "reply").lower()
170
- UPDATE_COOLDOWN_SEC = int(os.environ.get("UPDATE_COOLDOWN_SEC", "5"))
171
-
172
- # Media flags
173
- INCLUDE_MEDIA = os.environ.get("INCLUDE_MEDIA", "0") == "1"
174
- ALLOW_GIFS_VIDEOS = os.environ.get("ALLOW_GIFS_VIDEOS", "0") == "1"
175
- MAX_MEDIA_MB = int(os.environ.get("MAX_MEDIA_MB", "8"))
176
-
177
- # Thematic keywords + relevance threshold
178
- THEME_KEYWORDS = [kw.strip().lower() for kw in os.environ.get(
179
- "THEME_KEYWORDS",
180
- "pump,call,entry,entries,sl,tp,launch,buy,gem,bnb,eth,btc,sol,moon,ath,breakout,sol,$,aped"
181
- ).split(",") if kw.strip()]
182
- KEYWORD_WEIGHT = float(os.environ.get("KEYWORD_WEIGHT", "1.0"))
183
- FUZZ_WEIGHT = float(os.environ.get("FUZZ_WEIGHT", "0.7"))
184
- RELEVANCE_THRESHOLD = float(os.environ.get("RELEVANCE_THRESHOLD", "0.6"))
185
-
186
- # Phrases yang pasti exclude
187
- EXCLUDE_PHRASES = [p.strip().lower() for p in os.environ.get(
188
- "EXCLUDE_PHRASES",
189
- "achievement unlocked,call profit:,achieving +"
190
- ).split(",") if p.strip()]
191
-
192
- # ========= Client bootstrap =========
193
  def build_client() -> TelegramClient:
194
  if STRING_SESSION:
195
  print(">> Using StringSession (persistent).")
@@ -199,29 +190,26 @@ def build_client() -> TelegramClient:
199
 
200
  client = build_client()
201
 
202
- # Attach autotrack to the same Telethon client (guarded, with logs)
203
- print("[BOOT] setting up autotrack…", flush=True)
204
- try:
205
- from autotrack import setup_autotrack
206
- setup_autotrack(client, announce_chat=os.environ.get("TARGET_CHAT", "@MidasTouchsignalll"))
207
- print("[BOOT] autotrack attached ✓", flush=True)
208
- except Exception as e:
209
- import traceback
210
- print("[BOOT] autotrack attach failed:", repr(e), flush=True)
211
- traceback.print_exc()
212
 
 
 
 
213
  recent_hashes: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE)
214
- recent_content_hashes: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE) # content-only dedup
215
- recent_entity_keys: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE) # entity-based dedup
216
 
217
- # Peta id_chat -> "core" / "support"
218
- chat_roles: Dict[int, str] = {} # diisi saat startup setelah resolve entity
219
  startup_time_utc = datetime.now(timezone.utc)
220
 
221
-
222
- # ========= Persistence (SQLite) =========
223
- DB_PATH = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db")
224
-
225
  def _db():
226
  conn = sqlite3.connect(DB_PATH)
227
  conn.execute("PRAGMA journal_mode=WAL;")
@@ -284,8 +272,12 @@ def db_prune_expired(cutoff: datetime):
284
  conn.commit()
285
  conn.close()
286
 
 
 
287
 
288
- # ========= Utilities =========
 
 
289
  def debug_log(reason: str, content: str = "") -> None:
290
  short = (content or "").replace("\n", " ")[:160]
291
  print(f"[DEBUG] {reason}: {short}")
@@ -293,7 +285,7 @@ def debug_log(reason: str, content: str = "") -> None:
293
  def normalize_for_filter(text: str) -> str:
294
  if not text:
295
  return ""
296
- s = re.sub(r"(?m)^>.*", "", text)
297
  s = re.sub(r"\s+", " ", s).strip()
298
  return s
299
 
@@ -304,9 +296,9 @@ def _windows(tokens: List[str], size: int = 20):
304
  for i in range(0, len(tokens), size):
305
  yield " ".join(tokens[i : i + size])
306
 
307
- # --- Bersihkan URL/CA untuk skor relevansi ---
308
- CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana base58 (perkiraan)
309
- CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b") # EVM address
310
  CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*\S+", re.IGNORECASE)
311
 
312
  def _strip_urls_and_mentions(s: str) -> str:
@@ -365,10 +357,9 @@ def content_only_hash(text: str) -> str:
365
  norm = _strip_urls_and_mentions(normalize_for_filter(text))
366
  return hashlib.sha1(norm.encode("utf-8", errors="ignore")).hexdigest()
367
 
368
-
369
- # ========= Class aggregator (windowed unique groups) =========
370
- keyword_group_last_seen: defaultdict[str, dict[str, datetime]] = defaultdict(dict)
371
-
372
  def _prune_expired(now: datetime) -> None:
373
  window = timedelta(minutes=CLASS_WINDOW_MINUTES)
374
  cutoff = now - window
@@ -394,19 +385,21 @@ def update_and_classify(keyword: str, group_key: str, now: Optional[datetime] =
394
  if not now:
395
  now = datetime.now(timezone.utc)
396
  _prune_expired(now)
397
- bucket = keyword_group_last_seen[keyword]
398
  is_new_group = group_key not in bucket
399
  bucket[group_key] = now
 
400
  db_upsert_kw_seen(keyword, group_key, now)
401
  class_label, unique_groups = _classify_by_unique(len(bucket))
402
  return class_label, unique_groups, is_new_group
403
 
404
-
405
- # ========= Sentence-level invite filter =========
 
406
  INVITE_PATTERNS = [
407
  r"\bjoin\b", r"\bjoin (us|our|channel|group)\b",
408
  r"\bdm\b", r"\bdm (me|gw|gue|gua|saya|admin)\b",
409
- r"\bpm\b", r"\binbox\b", r"\bcontact\b", r"\bkontak\b", r"\bhubungi\b",
410
  r"\bvip\b", r"\bpremium\b", r"\bberbayar\b", r"\bpaid\b", r"\bexclusive\b",
411
  r"\bwhitelist\b", r"\bprivate( group| channel)?\b", r"\bmembership?\b",
412
  r"\bsubscribe\b", r"\blangganan\b",
@@ -440,8 +433,9 @@ def filter_invite_sentences(text: str) -> str:
440
  cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
441
  return cleaned
442
 
443
-
444
- # ========= Media helpers =========
 
445
  def is_image_message(msg) -> bool:
446
  if getattr(msg, "photo", None):
447
  return True
@@ -469,15 +463,11 @@ def media_too_big(msg) -> bool:
469
  return False
470
  return (size / (1024 * 1024)) > MAX_MEDIA_MB
471
 
472
-
473
- # ========= Post-on-threshold with EDIT/REPLY/NEW (persisted) =========
 
474
  TIER_ORDER = {"Low 🌱": 0, "Medium ⚡": 1, "Strong 💪": 2, "FOMO 🔥": 3}
475
 
476
- last_posted: Dict[str, Dict[str, object]] = {}
477
- last_body: Dict[str, str] = {}
478
- last_update_ts: Dict[str, float] = {}
479
-
480
- # ===== Custom: brand formatter for CA =====
481
  def _is_ca_key(keyword: str) -> bool:
482
  return keyword.startswith("ca:evm:") or keyword.startswith("ca:sol:")
483
 
@@ -488,7 +478,6 @@ def _ca_from_key(keyword: str) -> str:
488
  return keyword.split("ca:sol:", 1)[1]
489
  return ""
490
 
491
- # ===== Number format for MCAP line =====
492
  def _fmt_big_usd(x):
493
  if x is None:
494
  return "—"
@@ -504,55 +493,6 @@ def _fmt_big_usd(x):
504
  return f"${x/1_000:.2f}K"
505
  return f"${x:.0f}"
506
 
507
- # ===== Dexscreener fetch (MCAP/FDV) =====
508
- DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
509
-
510
- async def _fetch_initial_mcap(ca: str):
511
- """
512
- Ambil perkiraan MCAP (marketCap atau FDV) sekali dari Dexscreener.
513
- Return float atau None kalau gak ada.
514
- """
515
- try:
516
- timeout = aiohttp.ClientTimeout(total=8)
517
- async with aiohttp.ClientSession(timeout=timeout) as sess:
518
- async with sess.get(DEXSCREENER_TOKEN_URL + ca) as r:
519
- if r.status != 200:
520
- return None
521
- data = await r.json()
522
- pairs = (data or {}).get("pairs") or []
523
- if not pairs:
524
- return None
525
- # pilih pair dengan USD liquidity terbesar
526
- best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
527
- mc = best.get("marketCap")
528
- fdv = best.get("fdv")
529
- if isinstance(mc, (int, float)) and mc > 0:
530
- return float(mc)
531
- if isinstance(fdv, (int, float)) and fdv > 0:
532
- return float(fdv)
533
- return None
534
- except:
535
- return None
536
-
537
- # ===== Milestones label (sinkron dengan env; default 1.5× • 2×) =====
538
- _M_RAW = os.environ.get("MILESTONES", "1.5,2")
539
- try:
540
- _M_LIST = [x.strip() for x in _M_RAW.split(",") if x.strip()]
541
- if not _M_LIST:
542
- _M_LIST = ["1.5", "2"]
543
- MILESTONES_LABEL = " • ".join(f"{m}×" for m in _M_LIST)
544
- except Exception:
545
- _M_LIST = ["1.5", "2"]
546
- MILESTONES_LABEL = "1.5× • 2×"
547
-
548
- CHAIN_HINTS = {
549
- "bsc": "bsc", "bnb": "bsc", "binance": "bsc",
550
- "eth": "ethereum", "ethereum": "ethereum",
551
- "base": "base", "coinbase": "base",
552
- "sol": "solana", "solana": "solana", "pump.fun": "solana"
553
- }
554
-
555
-
556
  def _guess_chain_from_text(t: str) -> Optional[str]:
557
  t = (t or "").lower()
558
  for k, v in CHAIN_HINTS.items():
@@ -561,9 +501,6 @@ def _guess_chain_from_text(t: str) -> Optional[str]:
561
  return None
562
 
563
  async def _fetch_best_chain_for_ca(ca: str) -> Optional[str]:
564
- """
565
- Query Dexscreener token endpoint to pick the chainId with highest USD liquidity.
566
- """
567
  try:
568
  timeout = aiohttp.ClientTimeout(total=8)
569
  async with aiohttp.ClientSession(timeout=timeout) as sess:
@@ -576,53 +513,51 @@ async def _fetch_best_chain_for_ca(ca: str) -> Optional[str]:
576
  return None
577
  best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
578
  chain_id = (best.get("chainId") or "").lower().strip()
579
- # Dexscreener uses "ethereum", "bsc", "base", "solana", etc.
580
  return chain_id or None
581
  except:
582
  return None
583
 
584
  async def _dexs_link_universal(ca: str, context_text: Optional[str] = None) -> str:
585
- """
586
- Build the proper Dexscreener URL for a CA:
587
- - Solana: /solana/<ca>
588
- - EVM: /{ethereum|bsc|base}/<ca> (heuristic from text, fallback API, then default ethereum)
589
- - Fallback: /token/<ca>
590
- """
591
  # Solana
592
  if CA_SOL_RE.fullmatch(ca):
593
  return f"https://dexscreener.com/solana/{ca}"
594
-
595
  # EVM
596
  if CA_EVM_RE.fullmatch(ca):
597
- # (1) Heuristic from context text
598
  hint = _guess_chain_from_text(context_text or "")
599
  if hint:
600
  return f"https://dexscreener.com/{hint}/{ca.lower()}"
601
- # (2) Fallback query API to pick best chain by liquidity
602
  chain = await _fetch_best_chain_for_ca(ca)
603
  if chain:
604
  return f"https://dexscreener.com/{chain}/{ca.lower()}"
605
- # (3) Default
606
  return f"https://dexscreener.com/ethereum/{ca.lower()}"
607
-
608
- # Non-CA
609
  return f"https://dexscreener.com/token/{ca}"
610
 
611
- _M_LIST = ["1.5", "2"]
612
- MILESTONES_LABEL = "1.5× • 2×"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
 
614
- # ===== Creative CA message with MCAP + links =====
615
- def build_midas_message_for_ca(ca: str, tier_label: str, *, mcap_value=None, body_snippet: Optional[str] = None, dexs_link: Optional[str] = None) -> str:
616
- """
617
- Creative brand style untuk CA:
618
- - Tampilkan MCAP kalau tersedia
619
- - Sisipkan Dexscreener & Axiom
620
- - Copy 'first alert 1.5×' menuju discovery (no ceilings)
621
- """
622
- dexs_link = dexs_link
623
  axiom_link = "https://axiom.trade/@1144321"
624
  mcap_line = f"MCAP (est.): {_fmt_big_usd(mcap_value)}"
625
-
626
  first_alert = _M_LIST[0] if _M_LIST else "1.5"
627
 
628
  lines = [
@@ -642,49 +577,24 @@ def build_midas_message_for_ca(ca: str, tier_label: str, *, mcap_value=None, bod
642
  f"Auto-track armed → first alert at **{first_alert}×**; we hunt momentum to price discovery — no ceilings. 🎯",
643
  "",
644
  "Plan the trade, trade the plan. Cut losers quick, compound the wins.",
645
-
646
  ]
647
- msg = "\n".join(lines)
648
-
649
- # (opsional) sisipkan snippet sumber
650
- if body_snippet:
651
- snippet = re.sub(r"\n{3,}", "\n\n", body_snippet.strip())
652
- if snippet:
653
- msg += "\n\n> " + snippet.replace("\n", "\n> ")
654
-
655
- return msg
656
-
657
- def format_body_with_spacing(body: str, tier_label: str) -> str:
658
- if not body:
659
- return f"[{tier_label}]"
660
- raw_lines = [ln.strip() for ln in body.splitlines()]
661
- raw_lines = [ln for ln in raw_lines if ln != ""]
662
- formatted: List[str] = []
663
- trigger_words = ("stats", "ca", "links", "security", "trade carefully")
664
- for line in raw_lines:
665
- low = line.lower()
666
- if any(tw in low for tw in trigger_words):
667
- if formatted and formatted[-1] != "":
668
- formatted.append("")
669
- formatted.append(line)
670
- pretty = "\n".join(formatted)
671
- pretty = re.sub(r"\n{3,}", "\n\n", pretty).strip()
672
- return f"[{tier_label}]\n\n{pretty}"
673
-
674
- async def _send_initial(msg, text: str) -> int:
675
  if DRY_RUN:
676
  print("[DRY_RUN] send_initial:", text[:140])
677
  return -1
678
- if INCLUDE_MEDIA and is_image_message(msg) and not media_too_big(msg):
679
  try:
680
- if getattr(msg, "photo", None):
681
- m = await client.send_file(
682
- TARGET_CHAT, msg.photo, caption=text, caption_entities=None, force_document=False
683
- )
684
  return m.id
685
- doc = getattr(msg, "document", None)
686
  if doc:
687
- data = await client.download_media(msg, file=bytes)
688
  if data:
689
  bio = io.BytesIO(data)
690
  ext = ".jpg"
@@ -695,13 +605,11 @@ async def _send_initial(msg, text: str) -> int:
695
  ext_guess = ".jpg"
696
  ext = ext_guess
697
  bio.name = f"media{ext}"
698
- m = await client.send_file(
699
- TARGET_CHAT, bio, caption=text, caption_entities=None, force_document=False
700
- )
701
  return m.id
702
  except FloodWaitError as e:
703
  await asyncio.sleep(e.seconds + 1)
704
- return await _send_initial(msg, text)
705
  except Exception as e:
706
  debug_log("Gagal kirim media awal, fallback text", str(e))
707
  try:
@@ -709,43 +617,29 @@ async def _send_initial(msg, text: str) -> int:
709
  return m.id
710
  except FloodWaitError as e:
711
  await asyncio.sleep(e.seconds + 1)
712
- return await _send_initial(msg, text)
713
 
714
  async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, update_like: bool = False, allow_tier_upgrade: bool = True) -> None:
715
- """
716
- Modified:
717
- - If `keyword` represents a Contract Address (ca:evm:/ca:sol:),
718
- we format with brand style via build_midas_message_for_ca
719
- and menambahkan MCAP (fetch sekali sebelum kirim).
720
- """
721
  prev = last_posted.get(keyword)
722
  now_ts = datetime.now().timestamp()
723
 
724
- # Choose text based on whether this is a CA entity or not
725
  if _is_ca_key(keyword):
726
  ca_val = _ca_from_key(keyword)
727
- mcap_val = await _fetch_initial_mcap(ca_val) # ambil MCAP sekali
728
- # NEW: bangun URL Dexs yang benar (Sol/EVM/Base, dll)
729
  dexs_link = await _dexs_link_universal(ca_val, body)
730
- text_to_send = build_midas_message_for_ca(
731
- ca_val, new_tier,
732
- mcap_value=mcap_val,
733
- body_snippet=None,
734
- dexs_link=dexs_link,
735
- )
736
  else:
737
- text_to_send = format_body_with_spacing(body, new_tier)
 
738
 
739
  if not prev:
740
  msg_id = await _send_initial(src_msg, text_to_send)
741
  last_posted[keyword] = {"msg_id": msg_id, "tier": new_tier}
742
- last_body[keyword] = body
743
- last_update_ts[keyword] = now_ts
744
  if msg_id != -1:
745
  db_save_last_posted(keyword, msg_id, new_tier)
746
  return
747
 
748
- # Tier upgrade?
749
  if TIER_ORDER.get(new_tier, 0) > TIER_ORDER.get(prev["tier"], 0):
750
  if not allow_tier_upgrade:
751
  if not update_like:
@@ -757,8 +651,6 @@ async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, upd
757
  await client.send_message(TARGET_CHAT, text_to_send, reply_to=prev["msg_id"], link_preview=True)
758
  else:
759
  await client.send_message(TARGET_CHAT, text_to_send, link_preview=True)
760
- last_body[keyword] = body
761
- last_update_ts[keyword] = now_ts
762
  except FloodWaitError as e:
763
  await asyncio.sleep(e.seconds + 1)
764
  except Exception as e:
@@ -767,8 +659,6 @@ async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, upd
767
  try:
768
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
769
  prev["tier"] = new_tier
770
- last_body[keyword] = body
771
- last_update_ts[keyword] = now_ts
772
  if prev["msg_id"] != -1:
773
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
774
  except FloodWaitError as e:
@@ -778,36 +668,26 @@ async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, upd
778
  debug_log("Edit gagal (tier naik), fallback kirim baru", str(e))
779
  msg_id = await _send_initial(src_msg, text_to_send)
780
  last_posted[keyword] = {"msg_id": msg_id, "tier": new_tier}
781
- last_body[keyword] = body
782
- last_update_ts[keyword] = now_ts
783
  if msg_id != -1:
784
  db_save_last_posted(keyword, msg_id, new_tier)
785
  return
786
 
787
- # No tier upgrade; maybe update-like?
788
  if not update_like:
789
  return
790
- if body.strip() == last_body.get(keyword, "").strip() and (now_ts - last_update_ts.get(keyword, 0) < UPDATE_COOLDOWN_SEC):
791
- return
792
  try:
793
  if UPDATE_STRATEGY == "edit":
794
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
795
- last_body[keyword] = body
796
- last_update_ts[keyword] = now_ts
797
  if prev["msg_id"] != -1:
798
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
799
  elif UPDATE_STRATEGY == "reply":
800
  await client.send_message(TARGET_CHAT, text_to_send, reply_to=prev["msg_id"], link_preview=True)
801
- last_body[keyword] = body
802
- last_update_ts[keyword] = now_ts
803
  elif UPDATE_STRATEGY == "new":
804
  await client.send_message(TARGET_CHAT, text_to_send, link_preview=True)
805
- last_body[keyword] = body
806
- last_update_ts[keyword] = now_ts
807
  else:
808
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
809
- last_body[keyword] = body
810
- last_update_ts[keyword] = now_ts
811
  if prev["msg_id"] != -1:
812
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
813
  except FloodWaitError as e:
@@ -816,26 +696,23 @@ async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, upd
816
  except Exception as e:
817
  debug_log("Update gagal (strategy)", str(e))
818
 
819
-
820
- # ========= Core actions =========
 
821
  async def send_as_is(msg, text_override: Optional[str] = None) -> None:
822
  if DRY_RUN:
823
  print("[DRY_RUN] send_as_is:", (text_override or msg.message or "")[:140])
824
  return
825
-
826
  if text_override is not None:
827
  orig_text = text_override
828
  entities = None
829
  else:
830
  orig_text = msg.message or (getattr(msg, "raw_text", None) or "")
831
  entities = getattr(msg, "entities", None)
832
-
833
  if INCLUDE_MEDIA and is_image_message(msg) and not media_too_big(msg):
834
  try:
835
  if getattr(msg, "photo", None):
836
- await client.send_file(
837
- TARGET_CHAT, msg.photo, caption=orig_text, caption_entities=entities, force_document=False
838
- )
839
  return
840
  doc = getattr(msg, "document", None)
841
  if doc:
@@ -850,23 +727,21 @@ async def send_as_is(msg, text_override: Optional[str] = None) -> None:
850
  ext_guess = ".jpg"
851
  ext = ext_guess
852
  bio.name = f"media{ext}"
853
- await client.send_file(
854
- TARGET_CHAT, bio, caption=orig_text, caption_entities=entities, force_document=False
855
- )
856
  return
857
  except FloodWaitError as e:
858
  await asyncio.sleep(e.seconds + 1)
859
  except Exception as e:
860
  debug_log("Gagal kirim sebagai media, fallback ke text", str(e))
861
-
862
  try:
863
  await client.send_message(TARGET_CHAT, orig_text, formatting_entities=entities, link_preview=True)
864
  except FloodWaitError as e:
865
  await asyncio.sleep(e.seconds + 1)
866
  await client.send_message(TARGET_CHAT, orig_text, formatting_entities=entities, link_preview=True)
867
 
868
-
869
- # ========= Keyword & entity extraction =========
 
870
  TICKER_CLEAN_RE = re.compile(r"\$[A-Za-z0-9]{2,12}")
871
  TICKER_NOISY_RE = re.compile(r"\$[A-Za-z0-9](?:[^A-Za-z0-9]+[A-Za-z0-9]){1,11}")
872
 
@@ -885,43 +760,6 @@ def _extract_tickers(text_norm: str) -> List[str]:
885
  uniq.append(x); seen.add(x)
886
  return uniq
887
 
888
- def _extract_all_keywords(text_norm: str) -> List[str]:
889
- t = re.sub(r"\$([a-z0-9]+)", r"\1", text_norm, flags=re.I)
890
- found = []
891
- for kw in THEME_KEYWORDS:
892
- if re.search(rf"(^|\W){re.escape(kw)}(\W|$)", t, flags=re.I):
893
- found.append(kw.lower())
894
- found.extend(_extract_tickers(text_norm))
895
- seen = set(); uniq = []
896
- for x in found:
897
- if x not in seen:
898
- uniq.append(x); seen.add(x)
899
- return uniq
900
-
901
- def _choose_dominant_keyword(text_norm: str, kws: List[str]) -> Optional[str]:
902
- if not kws:
903
- return None
904
- score = {}
905
- for kw in kws:
906
- cnt = len(re.findall(rf"(^|\W){re.escape(kw)}(\W|$)", text_norm, flags=re.I))
907
- first = re.search(rf"(^|\W){re.escape(kw)}(\W|$)", text_norm, flags=re.I)
908
- first_idx = first.start() if first else 1_000_000
909
- bonus = 1 if kw.startswith("$") else 0
910
- score[kw] = (cnt, bonus, -first_idx)
911
- chosen = sorted(score.items(), key=lambda x: (x[1][0], x[1][1], x[1][2]), reverse=True)[0][0]
912
- return chosen
913
-
914
- def _role_of(chat_id: int) -> str:
915
- return chat_roles.get(chat_id, "support")
916
-
917
- def _unique_counts_by_role(keyword: str) -> Tuple[int, int]:
918
- bucket = keyword_group_last_seen.get(keyword, {})
919
- core_ids, sup_ids = set(), set()
920
- for gk in bucket.keys():
921
- role = chat_roles.get(int(gk), "support")
922
- (core_ids if role == "core" else sup_ids).add(gk)
923
- return len(core_ids), len(sup_ids)
924
-
925
  def extract_entity_key(text: str) -> Optional[str]:
926
  t = normalize_for_filter(text)
927
  m_evm = CA_EVM_RE.search(t)
@@ -931,13 +769,44 @@ def extract_entity_key(text: str) -> Optional[str]:
931
  return f"ca:evm:{m_evm.group(0).lower()}"
932
  else:
933
  return f"ca:sol:{m_sol.group(0)}"
 
934
  tickers = _extract_tickers(t.lower())
935
  if tickers:
936
  return f"ticker:{tickers[0][1:].lower()}"
937
  return None
938
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
 
940
- # ========= NEW: Filter Phanes di jalur umum =========
941
  async def _is_phanes_and_not_leaderboard(msg, text: str) -> bool:
942
  try:
943
  if getattr(msg, "via_bot_id", None) and PHANES_BOT_ID is not None:
@@ -951,6 +820,19 @@ async def _is_phanes_and_not_leaderboard(msg, text: str) -> bool:
951
  pass
952
  return False
953
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
 
955
  async def process_message(msg, source_chat_id: int) -> None:
956
  orig_text = msg.message or (getattr(msg, "raw_text", None) or "")
@@ -970,6 +852,7 @@ async def process_message(msg, source_chat_id: int) -> None:
970
  if not (entity_key and entity_key.startswith("ca:")):
971
  debug_log("Bukan pesan CA, dilewati", orig_text)
972
  return
 
973
  duplicate_entity = bool(entity_key and entity_key in recent_entity_keys)
974
 
975
  UPDATE_HINTS = [
@@ -1003,47 +886,39 @@ async def process_message(msg, source_chat_id: int) -> None:
1003
  if score < RELEVANCE_THRESHOLD and not allow_by_ca:
1004
  return
1005
 
 
1006
  role = _role_of(source_chat_id)
1007
- all_kws = _extract_all_keywords(text_norm)
1008
- main_kw = _choose_dominant_keyword(text_norm, all_kws)
1009
-
1010
- topic_key = entity_key
1011
- if not topic_key:
1012
- debug_log("Tak ada keyword/entitas cocok, dilewati", orig_text)
1013
- return
1014
 
 
1015
  group_key = str(source_chat_id)
1016
  now = datetime.now(timezone.utc)
1017
- class_label, unique_groups, is_new_group = update_and_classify(topic_key, group_key, now)
1018
 
1019
- # --- RULE BARU (global): Low hanya boleh dipost jika sudah >= LOW_MIN_UNIQUE call unik
1020
  if TIER_ORDER.get(class_label, 0) == TIER_ORDER["Low 🌱"] and unique_groups < LOW_MIN_UNIQUE:
1021
- debug_log(f"Tahan: butuh >= {LOW_MIN_UNIQUE} call untuk Low (unique_groups={unique_groups})", orig_text)
1022
  return
1023
 
1024
- # --- Rule lama: tahan support bila belum ada core & support unik belum cukup
1025
  if role != "core":
1026
- core_u, sup_u = _unique_counts_by_role(topic_key)
1027
  if core_u < 1 and sup_u < SUPPORT_MIN_UNIQUE:
1028
- debug_log(
1029
- f"Support ditahan (core_u={core_u}, sup_u={SUPPORT_MIN_UNIQUE})",
1030
- orig_text,
1031
- )
1032
  return
1033
 
1034
- # --- RULE BARU (khusus support): Support minimal Medium
1035
  if role == "support" and TIER_ORDER.get(class_label, 0) < TIER_ORDER["Medium ⚡"]:
1036
  debug_log("Support Low diblok (minimal Medium)", orig_text)
1037
  return
1038
 
 
1039
  cleaned_body = filter_invite_sentences(orig_text)
1040
  if not cleaned_body.strip():
1041
  debug_log("Semua kalimat terfilter (kosong), dilewati", orig_text)
1042
  return
1043
 
1044
- cutoff = startup_time_utc - timedelta(
1045
- minutes=CLASS_WINDOW_MINUTES + BACKFILL_BUFFER_MINUTES
1046
- )
1047
  if getattr(msg, "date", None):
1048
  msg_dt = msg.date
1049
  if isinstance(msg_dt, datetime) and msg_dt.replace(tzinfo=timezone.utc) < cutoff:
@@ -1054,7 +929,7 @@ async def process_message(msg, source_chat_id: int) -> None:
1054
  recent_entity_keys.append(entity_key)
1055
 
1056
  await post_or_update(
1057
- topic_key,
1058
  cleaned_body,
1059
  class_label,
1060
  msg,
@@ -1063,20 +938,27 @@ async def process_message(msg, source_chat_id: int) -> None:
1063
  )
1064
 
1065
  debug_log(
1066
- f"Posted/Edited (role={role}, unique_groups={unique_groups}, new_group={is_new_group}, key={topic_key}, tier={class_label}, update_like={update_like})",
1067
  orig_text,
1068
  )
1069
 
1070
-
1071
- # ========= Event handlers =========
 
1072
  @client.on(events.NewMessage(chats=SOURCE_CHATS))
1073
  async def on_new_message(event):
1074
  try:
1075
- await process_message(event.message, source_chat_id=abs(event.chat_id))
 
 
 
 
 
 
 
1076
  except Exception as e:
1077
  print(f"Process error di chat {event.chat_id}: {e}")
1078
 
1079
- # === Leaderboard listener (ketat) ===
1080
  @client.on(events.NewMessage(chats=(LEADERBOARD_GROUP,)))
1081
  async def on_leaderboard_reply(event):
1082
  try:
@@ -1105,23 +987,30 @@ async def on_leaderboard_reply(event):
1105
  if not _is_true_leaderboard(text):
1106
  return
1107
 
 
1108
  global _last_lb_hash, _last_lb_ts
 
 
 
 
 
 
1109
  h = _hash_text_1line(_normalize_lb_for_hash(text))
1110
- now = asyncio.get_event_loop().time()
1111
- if _last_lb_hash == h and _last_lb_ts is not None and (now - _last_lb_ts) < LEADERBOARD_COOLDOWN_SEC:
1112
  return
1113
  _last_lb_hash = h
1114
- _last_lb_ts = now
1115
 
1116
  await send_as_is(msg)
1117
  debug_log("Forward Leaderboard", text[:120])
1118
  except Exception as e:
1119
  debug_log("LB forward error", str(e))
1120
 
1121
-
1122
- # ========= Scheduler: /lb acak per JAM =========
 
1123
  async def periodic_lb_trigger():
1124
- """Kirim /lb ke LEADERBOARD_GROUP tiap interval acak (jam)."""
1125
  try:
1126
  lb_ent = await client.get_entity(LEADERBOARD_GROUP)
1127
  except Exception as e:
@@ -1142,90 +1031,23 @@ async def periodic_lb_trigger():
1142
 
1143
  await client.send_message(lb_ent, LB_TRIGGER)
1144
  print("[LB-SCHED] /lb sent")
1145
- except FloodWaitError as fw:
1146
- print(f"[LB-SCHED] FloodWait {fw.seconds}s, menunggu...")
1147
- await asyncio.sleep(fw.seconds + 2)
1148
- except Exception as e:
1149
- print(f"[LB-SCHED] Error: {e} — cooldown 5m & lanjut")
1150
- await asyncio.sleep(300)
1151
-
1152
-
1153
- # ========= Entry points =========
1154
- async def _resolve_and_tag_chats(raw_list, role_label: str) -> list:
1155
- resolved = []
1156
- for src in raw_list:
1157
- try:
1158
- ent = await client.get_entity(src)
1159
- resolved.append(ent)
1160
- chat_roles[abs(int(ent.id))] = role_label
1161
  except Exception as e:
1162
- print(f"Gagal resolve sumber {src}: {e}")
1163
- return resolved
1164
 
1165
-
1166
- async def start_bot_background() -> None:
 
 
1167
  await client.start()
1168
- _init_db()
1169
-
1170
- global last_posted, keyword_group_last_seen, PHANES_BOT_ID
1171
- last_posted, keyword_group_last_seen = db_load_state()
1172
-
1173
- await _resolve_and_tag_chats(CORE_CHATS, "core")
1174
- await _resolve_and_tag_chats(SUPPORT_CHATS, "support")
1175
-
1176
  try:
1177
- ph_ent = await client.get_entity(LEADERBOARD_BOT)
1178
- PHANES_BOT_ID = abs(int(ph_ent.id))
1179
- print(f"Resolved Phanes bot id: {PHANES_BOT_ID}")
1180
  except Exception as e:
1181
- print(f"Gagal resolve LEADERBOARD_BOT: {e} (fallback pola teks)")
1182
-
1183
- # (opsional) trigger awal
1184
- try:
1185
- lb_ent = await client.get_entity(LEADERBOARD_GROUP)
1186
- await client.send_message(lb_ent, LB_TRIGGER)
1187
- print("/lb dikirim untuk percobaan. Menunggu balasan bot...")
1188
- except Exception as e:
1189
- print(f"Gagal resolve/trigger leaderboard group: {e}")
1190
-
1191
- # === START SCHEDULER /lb acak per JAM ===
1192
- asyncio.create_task(periodic_lb_trigger())
1193
-
1194
- print("Kurator berjalan (background task). Menunggu pesan baru...")
1195
- asyncio.create_task(client.run_until_disconnected())
1196
-
1197
-
1198
- async def app_main() -> None:
1199
- await client.start()
1200
- _init_db()
1201
-
1202
- global last_posted, keyword_group_last_seen, PHANES_BOT_ID
1203
- last_posted, keyword_group_last_seen = db_load_state()
1204
-
1205
- await _resolve_and_tag_chats(CORE_CHATS, "core")
1206
- await _resolve_and_tag_chats(SUPPORT_CHATS, "support")
1207
-
1208
- try:
1209
- ph_ent = await client.get_entity(LEADERBOARD_BOT)
1210
- PHANES_BOT_ID = abs(int(ph_ent.id))
1211
- print(f"Resolved Phanes bot id: {PHANES_BOT_ID}")
1212
- except Exception as e:
1213
- print(f"Gagal resolve LEADERBOARD_BOT: {e} (fallback pola teks)")
1214
-
1215
- # (opsional) trigger awal
1216
- try:
1217
- lb_ent = await client.get_entity(LEADERBOARD_GROUP)
1218
- await client.send_message(lb_ent, LB_TRIGGER)
1219
- print("/lb dikirim untuk percobaan. Menunggu balasan bot...")
1220
- except Exception as e:
1221
- print(f"Gagal trigger leaderboard group: {e}")
1222
-
1223
- # === START SCHEDULER /lb acak per JAM ===
1224
- asyncio.create_task(periodic_lb_trigger())
1225
-
1226
- print("Kurator berjalan. Menunggu pesan baru... (Stop dengan interrupt).")
1227
  await client.run_until_disconnected()
1228
 
1229
-
1230
  if __name__ == "__main__":
1231
- asyncio.run(app_main())
 
1
+ # botsignal.py — CA-only aggregator + brand message + autotrack attach (full)
2
+
3
  import asyncio
4
  import os
5
  import re
 
18
  from telethon.sessions import StringSession, MemorySession
19
  from telethon.errors.rpcerrorlist import FloodWaitError
20
 
21
+ # =========================
22
+ # Attach autotrack (pakai client yang sama)
23
+ # =========================
24
+ print("[BOOT] setting up autotrack…", flush=True)
25
+ try:
26
+ from autotrack import setup_autotrack # pastikan autotrack.py versi fix
27
+ _HAS_AUTOTRACK = True
28
+ except Exception as e:
29
+ print("[BOOT] autotrack import failed:", repr(e), flush=True)
30
+ _HAS_AUTOTRACK = False
31
 
32
+ # =========================
33
+ # ENV / CONFIG
34
+ # =========================
35
  API_ID = int(os.environ.get("API_ID", "0"))
36
+ API_HASH = os.environ.get("API_HASH", "")
37
  STRING_SESSION = os.environ.get("STRING_SESSION", "")
38
 
39
+ # Target posting / reply
40
+ TARGET_CHAT = os.environ.get("TARGET_CHAT", "https://t.me/MidasTouchsignalll")
41
+
42
+ # ====== Leaderboard config (Phanes) ======
43
+ LEADERBOARD_GROUP = os.environ.get("LEADERBOARD_GROUP", "https://t.me/+IaefBrMZwW5kMTEx")
44
+ LB_TRIGGER = os.environ.get("LB_TRIGGER", "/lb")
45
+ LEADERBOARD_BOT = os.environ.get("LEADERBOARD_BOT", "@PhanesGreenBot")
46
+ LB_REQUIRE_MIN_RANKS = int(os.environ.get("LB_REQUIRE_MIN_RANKS", "0"))
47
+ LEADERBOARD_COOLDOWN_SEC = int(os.environ.get("LEADERBOARD_COOLDOWN_SEC", "600"))
48
+
49
+ # ====== Scheduler interval (acak per JAM) ======
50
+ LB_INTERVAL_MIN_HOURS = int(os.environ.get("LB_INTERVAL_MIN_HOURS", "3"))
51
+ LB_INTERVAL_MAX_HOURS = int(os.environ.get("LB_INTERVAL_MAX_HOURS", "20"))
52
+
53
+ # ====== Dedup & window ======
54
+ INITIAL_BACKFILL = 2
55
+ DEDUP_BUFFER_SIZE = int(os.environ.get("DEDUP_BUFFER_SIZE", "800"))
56
+ CLASS_WINDOW_MINUTES = int(os.environ.get("CLASS_WINDOW_MINUTES", "180"))
57
+ BACKFILL_BUFFER_MINUTES = int(os.environ.get("BACKFILL_BUFFER_MINUTES", "3"))
58
+
59
+ # ====== Gate Core/Support ======
60
+ SUPPORT_MIN_UNIQUE = int(os.environ.get("SUPPORT_MIN_UNIQUE", "2"))
61
+ LOW_MIN_UNIQUE = int(os.environ.get("LOW_MIN_UNIQUE", "2"))
62
+
63
+ # ====== Behavior ======
64
+ DRY_RUN = os.environ.get("DRY_RUN", "0") == "1"
65
+ UPDATE_STRATEGY = os.environ.get("UPDATE_STRATEGY", "reply").lower() # edit|reply|new
66
+ UPDATE_COOLDOWN_SEC = int(os.environ.get("UPDATE_COOLDOWN_SEC", "5"))
67
+
68
+ # ====== Media flags ======
69
+ INCLUDE_MEDIA = os.environ.get("INCLUDE_MEDIA", "0") == "1"
70
+ ALLOW_GIFS_VIDEOS = os.environ.get("ALLOW_GIFS_VIDEOS", "0") == "1"
71
+ MAX_MEDIA_MB = int(os.environ.get("MAX_MEDIA_MB", "8"))
72
+
73
+ # ====== Keywords & filter ======
74
+ THEME_KEYWORDS = [kw.strip().lower() for kw in os.environ.get(
75
+ "THEME_KEYWORDS",
76
+ "pump,call,entry,entries,sl,tp,launch,buy,gem,bnb,eth,btc,sol,moon,ath,breakout,sol,$,aped"
77
+ ).split(",") if kw.strip()]
78
+ KEYWORD_WEIGHT = float(os.environ.get("KEYWORD_WEIGHT", "1.0"))
79
+ FUZZ_WEIGHT = float(os.environ.get("FUZZ_WEIGHT", "0.7"))
80
+ RELEVANCE_THRESHOLD = float(os.environ.get("RELEVANCE_THRESHOLD", "0.6"))
81
+
82
+ EXCLUDE_PHRASES = [p.strip().lower() for p in os.environ.get(
83
+ "EXCLUDE_PHRASES",
84
+ "achievement unlocked,call profit:,achieving +"
85
+ ).split(",") if p.strip()]
86
+
87
+ # ====== Milestones label (untuk copy di pesan awal CA) ======
88
+ _M_RAW = os.environ.get("MILESTONES", "1.5,2")
89
+ try:
90
+ _M_LIST = [x.strip() for x in _M_RAW.split(",") if x.strip()]
91
+ if not _M_LIST:
92
+ _M_LIST = ["1.5", "2"]
93
+ MILESTONES_LABEL = " • ".join(f"{m}×" for m in _M_LIST)
94
+ except Exception:
95
+ _M_LIST = ["1.5", "2"]
96
+ MILESTONES_LABEL = "1.5× • 2×"
97
+
98
+ # ====== Chains (Dexscreener)
99
+ DEXSCREENER_TOKEN_URL = "https://api.dexscreener.com/latest/dex/tokens/"
100
+
101
+ CHAIN_HINTS = {
102
+ "bsc": "bsc", "bnb": "bsc", "binance": "bsc",
103
+ "eth": "ethereum", "ethereum": "ethereum",
104
+ "base": "base", "coinbase": "base",
105
+ "sol": "solana", "solana": "solana", "pump.fun": "solana"
106
+ }
107
+
108
+ # ====== DB ======
109
+ DB_PATH = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db")
110
+
111
+ # =========================
112
+ # Sources (CORE & SUPPORT)
113
+ # =========================
114
  CORE_CHATS = [
115
  "https://t.me/PEPE_Calls28",
116
  "https://t.me/SephirothGemCalls1",
 
178
  ]
179
  SOURCE_CHATS = CORE_CHATS + SUPPORT_CHATS
180
 
181
+ # =========================
182
+ # Boot client
183
+ # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def build_client() -> TelegramClient:
185
  if STRING_SESSION:
186
  print(">> Using StringSession (persistent).")
 
190
 
191
  client = build_client()
192
 
193
+ # Attach autotrack ke client yang sama
194
+ if _HAS_AUTOTRACK:
195
+ try:
196
+ setup_autotrack(client, announce_chat=TARGET_CHAT)
197
+ print("[BOOT] autotrack attached ✓", flush=True)
198
+ except Exception as e:
199
+ import traceback
200
+ print("[BOOT] autotrack attach failed:", repr(e), flush=True)
201
+ traceback.print_exc()
 
202
 
203
+ # =========================
204
+ # State & DB
205
+ # =========================
206
  recent_hashes: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE)
207
+ recent_content_hashes: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE)
208
+ recent_entity_keys: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE)
209
 
210
+ chat_roles: Dict[int, str] = {} # id_chat -> "core"/"support"
 
211
  startup_time_utc = datetime.now(timezone.utc)
212
 
 
 
 
 
213
  def _db():
214
  conn = sqlite3.connect(DB_PATH)
215
  conn.execute("PRAGMA journal_mode=WAL;")
 
272
  conn.commit()
273
  conn.close()
274
 
275
+ _init_db()
276
+ last_posted, keyword_group_last_seen = db_load_state()
277
 
278
+ # =========================
279
+ # Utils
280
+ # =========================
281
  def debug_log(reason: str, content: str = "") -> None:
282
  short = (content or "").replace("\n", " ")[:160]
283
  print(f"[DEBUG] {reason}: {short}")
 
285
  def normalize_for_filter(text: str) -> str:
286
  if not text:
287
  return ""
288
+ s = re.sub(r"(?m)^>.*", "", text) # strip blockquotes
289
  s = re.sub(r"\s+", " ", s).strip()
290
  return s
291
 
 
296
  for i in range(0, len(tokens), size):
297
  yield " ".join(tokens[i : i + size])
298
 
299
+ # --- CA patterns & cleaners ---
300
+ CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana
301
+ CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b")
302
  CA_LABEL_RE = re.compile(r"\bCA\s*[:=]\s*\S+", re.IGNORECASE)
303
 
304
  def _strip_urls_and_mentions(s: str) -> str:
 
357
  norm = _strip_urls_and_mentions(normalize_for_filter(text))
358
  return hashlib.sha1(norm.encode("utf-8", errors="ignore")).hexdigest()
359
 
360
+ # =========================
361
+ # Class aggregator (windowed)
362
+ # =========================
 
363
  def _prune_expired(now: datetime) -> None:
364
  window = timedelta(minutes=CLASS_WINDOW_MINUTES)
365
  cutoff = now - window
 
385
  if not now:
386
  now = datetime.now(timezone.utc)
387
  _prune_expired(now)
388
+ bucket = keyword_group_last_seen.get(keyword, {})
389
  is_new_group = group_key not in bucket
390
  bucket[group_key] = now
391
+ keyword_group_last_seen[keyword] = bucket
392
  db_upsert_kw_seen(keyword, group_key, now)
393
  class_label, unique_groups = _classify_by_unique(len(bucket))
394
  return class_label, unique_groups, is_new_group
395
 
396
+ # =========================
397
+ # Sentence-level invite filter
398
+ # =========================
399
  INVITE_PATTERNS = [
400
  r"\bjoin\b", r"\bjoin (us|our|channel|group)\b",
401
  r"\bdm\b", r"\bdm (me|gw|gue|gua|saya|admin)\b",
402
+ r"\bpm\b", r"\binbox\b", r"\bcontact\b", r"\bhubungi\b", r"\bkontak\b",
403
  r"\bvip\b", r"\bpremium\b", r"\bberbayar\b", r"\bpaid\b", r"\bexclusive\b",
404
  r"\bwhitelist\b", r"\bprivate( group| channel)?\b", r"\bmembership?\b",
405
  r"\bsubscribe\b", r"\blangganan\b",
 
433
  cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
434
  return cleaned
435
 
436
+ # =========================
437
+ # Media helpers
438
+ # =========================
439
  def is_image_message(msg) -> bool:
440
  if getattr(msg, "photo", None):
441
  return True
 
463
  return False
464
  return (size / (1024 * 1024)) > MAX_MEDIA_MB
465
 
466
+ # =========================
467
+ # CA helpers & message builder
468
+ # =========================
469
  TIER_ORDER = {"Low 🌱": 0, "Medium ⚡": 1, "Strong 💪": 2, "FOMO 🔥": 3}
470
 
 
 
 
 
 
471
  def _is_ca_key(keyword: str) -> bool:
472
  return keyword.startswith("ca:evm:") or keyword.startswith("ca:sol:")
473
 
 
478
  return keyword.split("ca:sol:", 1)[1]
479
  return ""
480
 
 
481
  def _fmt_big_usd(x):
482
  if x is None:
483
  return "—"
 
493
  return f"${x/1_000:.2f}K"
494
  return f"${x:.0f}"
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  def _guess_chain_from_text(t: str) -> Optional[str]:
497
  t = (t or "").lower()
498
  for k, v in CHAIN_HINTS.items():
 
501
  return None
502
 
503
  async def _fetch_best_chain_for_ca(ca: str) -> Optional[str]:
 
 
 
504
  try:
505
  timeout = aiohttp.ClientTimeout(total=8)
506
  async with aiohttp.ClientSession(timeout=timeout) as sess:
 
513
  return None
514
  best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
515
  chain_id = (best.get("chainId") or "").lower().strip()
 
516
  return chain_id or None
517
  except:
518
  return None
519
 
520
  async def _dexs_link_universal(ca: str, context_text: Optional[str] = None) -> str:
 
 
 
 
 
 
521
  # Solana
522
  if CA_SOL_RE.fullmatch(ca):
523
  return f"https://dexscreener.com/solana/{ca}"
 
524
  # EVM
525
  if CA_EVM_RE.fullmatch(ca):
 
526
  hint = _guess_chain_from_text(context_text or "")
527
  if hint:
528
  return f"https://dexscreener.com/{hint}/{ca.lower()}"
 
529
  chain = await _fetch_best_chain_for_ca(ca)
530
  if chain:
531
  return f"https://dexscreener.com/{chain}/{ca.lower()}"
 
532
  return f"https://dexscreener.com/ethereum/{ca.lower()}"
533
+ # non-CA fallback
 
534
  return f"https://dexscreener.com/token/{ca}"
535
 
536
+ async def _fetch_initial_mcap(ca: str):
537
+ try:
538
+ timeout = aiohttp.ClientTimeout(total=8)
539
+ async with aiohttp.ClientSession(timeout=timeout) as sess:
540
+ async with sess.get(DEXSCREENER_TOKEN_URL + ca) as r:
541
+ if r.status != 200:
542
+ return None
543
+ data = await r.json()
544
+ pairs = (data or {}).get("pairs") or []
545
+ if not pairs:
546
+ return None
547
+ best = max(pairs, key=lambda p: (p.get("liquidity", {}) or {}).get("usd", 0))
548
+ mc = best.get("marketCap")
549
+ fdv = best.get("fdv")
550
+ if isinstance(mc, (int, float)) and mc > 0:
551
+ return float(mc)
552
+ if isinstance(fdv, (int, float)) and fdv > 0:
553
+ return float(fdv)
554
+ return None
555
+ except:
556
+ return None
557
 
558
+ def build_midas_message_for_ca(ca: str, tier_label: str, *, mcap_value=None, dexs_link: Optional[str] = None) -> str:
 
 
 
 
 
 
 
 
559
  axiom_link = "https://axiom.trade/@1144321"
560
  mcap_line = f"MCAP (est.): {_fmt_big_usd(mcap_value)}"
 
561
  first_alert = _M_LIST[0] if _M_LIST else "1.5"
562
 
563
  lines = [
 
577
  f"Auto-track armed → first alert at **{first_alert}×**; we hunt momentum to price discovery — no ceilings. 🎯",
578
  "",
579
  "Plan the trade, trade the plan. Cut losers quick, compound the wins.",
 
580
  ]
581
+ return "\n".join(lines)
582
+
583
+ # =========================
584
+ # Post helpers
585
+ # =========================
586
+ async def _send_initial(src_msg, text: str) -> int:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  if DRY_RUN:
588
  print("[DRY_RUN] send_initial:", text[:140])
589
  return -1
590
+ if INCLUDE_MEDIA and is_image_message(src_msg) and not media_too_big(src_msg):
591
  try:
592
+ if getattr(src_msg, "photo", None):
593
+ m = await client.send_file(TARGET_CHAT, src_msg.photo, caption=text, caption_entities=None, force_document=False)
 
 
594
  return m.id
595
+ doc = getattr(src_msg, "document", None)
596
  if doc:
597
+ data = await client.download_media(src_msg, file=bytes)
598
  if data:
599
  bio = io.BytesIO(data)
600
  ext = ".jpg"
 
605
  ext_guess = ".jpg"
606
  ext = ext_guess
607
  bio.name = f"media{ext}"
608
+ m = await client.send_file(TARGET_CHAT, bio, caption=text, caption_entities=None, force_document=False)
 
 
609
  return m.id
610
  except FloodWaitError as e:
611
  await asyncio.sleep(e.seconds + 1)
612
+ return await _send_initial(src_msg, text)
613
  except Exception as e:
614
  debug_log("Gagal kirim media awal, fallback text", str(e))
615
  try:
 
617
  return m.id
618
  except FloodWaitError as e:
619
  await asyncio.sleep(e.seconds + 1)
620
+ return await _send_initial(src_msg, text)
621
 
622
  async def post_or_update(keyword: str, body: str, new_tier: str, src_msg, *, update_like: bool = False, allow_tier_upgrade: bool = True) -> None:
 
 
 
 
 
 
623
  prev = last_posted.get(keyword)
624
  now_ts = datetime.now().timestamp()
625
 
 
626
  if _is_ca_key(keyword):
627
  ca_val = _ca_from_key(keyword)
628
+ mcap_val = await _fetch_initial_mcap(ca_val)
 
629
  dexs_link = await _dexs_link_universal(ca_val, body)
630
+ text_to_send = build_midas_message_for_ca(ca_val, new_tier, mcap_value=mcap_val, dexs_link=dexs_link)
 
 
 
 
 
631
  else:
632
+ # fallback (non-CA) — jarang dipakai karena filter CA-only
633
+ text_to_send = f"[{new_tier}]\n\n{body}"
634
 
635
  if not prev:
636
  msg_id = await _send_initial(src_msg, text_to_send)
637
  last_posted[keyword] = {"msg_id": msg_id, "tier": new_tier}
 
 
638
  if msg_id != -1:
639
  db_save_last_posted(keyword, msg_id, new_tier)
640
  return
641
 
642
+ # tier upgrade
643
  if TIER_ORDER.get(new_tier, 0) > TIER_ORDER.get(prev["tier"], 0):
644
  if not allow_tier_upgrade:
645
  if not update_like:
 
651
  await client.send_message(TARGET_CHAT, text_to_send, reply_to=prev["msg_id"], link_preview=True)
652
  else:
653
  await client.send_message(TARGET_CHAT, text_to_send, link_preview=True)
 
 
654
  except FloodWaitError as e:
655
  await asyncio.sleep(e.seconds + 1)
656
  except Exception as e:
 
659
  try:
660
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
661
  prev["tier"] = new_tier
 
 
662
  if prev["msg_id"] != -1:
663
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
664
  except FloodWaitError as e:
 
668
  debug_log("Edit gagal (tier naik), fallback kirim baru", str(e))
669
  msg_id = await _send_initial(src_msg, text_to_send)
670
  last_posted[keyword] = {"msg_id": msg_id, "tier": new_tier}
 
 
671
  if msg_id != -1:
672
  db_save_last_posted(keyword, msg_id, new_tier)
673
  return
674
 
675
+ # no tier upgrade; maybe update-like?
676
  if not update_like:
677
  return
678
+ # cooldown
679
+ # (opsional: track last_update_ts jika ingin)
680
  try:
681
  if UPDATE_STRATEGY == "edit":
682
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
 
 
683
  if prev["msg_id"] != -1:
684
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
685
  elif UPDATE_STRATEGY == "reply":
686
  await client.send_message(TARGET_CHAT, text_to_send, reply_to=prev["msg_id"], link_preview=True)
 
 
687
  elif UPDATE_STRATEGY == "new":
688
  await client.send_message(TARGET_CHAT, text_to_send, link_preview=True)
 
 
689
  else:
690
  await client.edit_message(TARGET_CHAT, prev["msg_id"], text_to_send)
 
 
691
  if prev["msg_id"] != -1:
692
  db_save_last_posted(keyword, prev["msg_id"], new_tier)
693
  except FloodWaitError as e:
 
696
  except Exception as e:
697
  debug_log("Update gagal (strategy)", str(e))
698
 
699
+ # =========================
700
+ # Raw forward helper
701
+ # =========================
702
  async def send_as_is(msg, text_override: Optional[str] = None) -> None:
703
  if DRY_RUN:
704
  print("[DRY_RUN] send_as_is:", (text_override or msg.message or "")[:140])
705
  return
 
706
  if text_override is not None:
707
  orig_text = text_override
708
  entities = None
709
  else:
710
  orig_text = msg.message or (getattr(msg, "raw_text", None) or "")
711
  entities = getattr(msg, "entities", None)
 
712
  if INCLUDE_MEDIA and is_image_message(msg) and not media_too_big(msg):
713
  try:
714
  if getattr(msg, "photo", None):
715
+ await client.send_file(TARGET_CHAT, msg.photo, caption=orig_text, caption_entities=entities, force_document=False)
 
 
716
  return
717
  doc = getattr(msg, "document", None)
718
  if doc:
 
727
  ext_guess = ".jpg"
728
  ext = ext_guess
729
  bio.name = f"media{ext}"
730
+ await client.send_file(TARGET_CHAT, bio, caption=orig_text, caption_entities=entities, force_document=False)
 
 
731
  return
732
  except FloodWaitError as e:
733
  await asyncio.sleep(e.seconds + 1)
734
  except Exception as e:
735
  debug_log("Gagal kirim sebagai media, fallback ke text", str(e))
 
736
  try:
737
  await client.send_message(TARGET_CHAT, orig_text, formatting_entities=entities, link_preview=True)
738
  except FloodWaitError as e:
739
  await asyncio.sleep(e.seconds + 1)
740
  await client.send_message(TARGET_CHAT, orig_text, formatting_entities=entities, link_preview=True)
741
 
742
+ # =========================
743
+ # Keyword & entity extraction
744
+ # =========================
745
  TICKER_CLEAN_RE = re.compile(r"\$[A-Za-z0-9]{2,12}")
746
  TICKER_NOISY_RE = re.compile(r"\$[A-Za-z0-9](?:[^A-Za-z0-9]+[A-Za-z0-9]){1,11}")
747
 
 
760
  uniq.append(x); seen.add(x)
761
  return uniq
762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  def extract_entity_key(text: str) -> Optional[str]:
764
  t = normalize_for_filter(text)
765
  m_evm = CA_EVM_RE.search(t)
 
769
  return f"ca:evm:{m_evm.group(0).lower()}"
770
  else:
771
  return f"ca:sol:{m_sol.group(0)}"
772
+ # jika ingin dukung ticker: return "ticker:<sym>"
773
  tickers = _extract_tickers(t.lower())
774
  if tickers:
775
  return f"ticker:{tickers[0][1:].lower()}"
776
  return None
777
 
778
+ # =========================
779
+ # Phanes / leaderboard filter
780
+ # =========================
781
+ PHANES_BOT_ID: Optional[int] = None
782
+ LB_TEXT_RE = re.compile(r"(🏆\s*Leaderboard|📊\s*Group\s*Stats)", re.IGNORECASE)
783
+ ZERO_WIDTH_RE = re.compile(r"[\u200b\u200c\u200d\u2060\ufeff]")
784
+
785
+ def _hash_text_1line(t: str) -> str:
786
+ t1 = re.sub(r"\s+", " ", (t or "")).strip()
787
+ return hashlib.sha1(t1.encode("utf-8", errors="ignore")).hexdigest()
788
+
789
+ def _normalize_lb_for_hash(t: str) -> str:
790
+ if not t:
791
+ return ""
792
+ t = ZERO_WIDTH_RE.sub("", t)
793
+ t = re.sub(r"\b\d+(\.\d+)?%?\b", "<n>", t) # angka volatile
794
+ t = re.sub(r"\s+", " ", t).strip().lower()
795
+ return t
796
+
797
+ def _is_true_leaderboard(text: str) -> bool:
798
+ if not text:
799
+ return False
800
+ if not re.search(r"🏆\s*Leaderboard", text):
801
+ return False
802
+ if not re.search(r"📊\s*Group\s*Stats", text):
803
+ return False
804
+ if LB_REQUIRE_MIN_RANKS > 0:
805
+ ranks = re.findall(r"(?m)^\s*[\W\s]*\d{1,2}\s+.+\[[\d\.]+x\]\s*$", text)
806
+ if len(ranks) < LB_REQUIRE_MIN_RANKS:
807
+ return False
808
+ return True
809
 
 
810
  async def _is_phanes_and_not_leaderboard(msg, text: str) -> bool:
811
  try:
812
  if getattr(msg, "via_bot_id", None) and PHANES_BOT_ID is not None:
 
820
  pass
821
  return False
822
 
823
+ # =========================
824
+ # Core processing
825
+ # =========================
826
+ def _role_of(chat_id: int) -> str:
827
+ return chat_roles.get(chat_id, "support")
828
+
829
+ def _unique_counts_by_role(keyword: str) -> Tuple[int, int]:
830
+ bucket = keyword_group_last_seen.get(keyword, {})
831
+ core_ids, sup_ids = set(), set()
832
+ for gk in bucket.keys():
833
+ role = chat_roles.get(int(gk), "support")
834
+ (core_ids if role == "core" else sup_ids).add(gk)
835
+ return len(core_ids), len(sup_ids)
836
 
837
  async def process_message(msg, source_chat_id: int) -> None:
838
  orig_text = msg.message or (getattr(msg, "raw_text", None) or "")
 
852
  if not (entity_key and entity_key.startswith("ca:")):
853
  debug_log("Bukan pesan CA, dilewati", orig_text)
854
  return
855
+
856
  duplicate_entity = bool(entity_key and entity_key in recent_entity_keys)
857
 
858
  UPDATE_HINTS = [
 
886
  if score < RELEVANCE_THRESHOLD and not allow_by_ca:
887
  return
888
 
889
+ # tentukan role
890
  role = _role_of(source_chat_id)
 
 
 
 
 
 
 
891
 
892
+ # update bucket unik
893
  group_key = str(source_chat_id)
894
  now = datetime.now(timezone.utc)
895
+ class_label, unique_groups, is_new_group = update_and_classify(entity_key, group_key, now)
896
 
897
+ # RULE: Low harus >= LOW_MIN_UNIQUE unik
898
  if TIER_ORDER.get(class_label, 0) == TIER_ORDER["Low 🌱"] and unique_groups < LOW_MIN_UNIQUE:
899
+ debug_log(f"Tahan Low: butuh >= {LOW_MIN_UNIQUE} call (unique_groups={unique_groups})", orig_text)
900
  return
901
 
902
+ # Rule lama: tahan support bila belum ada core & support unik belum cukup
903
  if role != "core":
904
+ core_u, sup_u = _unique_counts_by_role(entity_key)
905
  if core_u < 1 and sup_u < SUPPORT_MIN_UNIQUE:
906
+ debug_log(f"Support ditahan (core_u={core_u}, sup_u<{SUPPORT_MIN_UNIQUE})", orig_text)
 
 
 
907
  return
908
 
909
+ # RULE BARU (khusus support): Support minimal Medium
910
  if role == "support" and TIER_ORDER.get(class_label, 0) < TIER_ORDER["Medium ⚡"]:
911
  debug_log("Support Low diblok (minimal Medium)", orig_text)
912
  return
913
 
914
+ # bersihkan ajakan/iklan
915
  cleaned_body = filter_invite_sentences(orig_text)
916
  if not cleaned_body.strip():
917
  debug_log("Semua kalimat terfilter (kosong), dilewati", orig_text)
918
  return
919
 
920
+ # cutoff backfill
921
+ cutoff = startup_time_utc - timedelta(minutes=CLASS_WINDOW_MINUTES + BACKFILL_BUFFER_MINUTES)
 
922
  if getattr(msg, "date", None):
923
  msg_dt = msg.date
924
  if isinstance(msg_dt, datetime) and msg_dt.replace(tzinfo=timezone.utc) < cutoff:
 
929
  recent_entity_keys.append(entity_key)
930
 
931
  await post_or_update(
932
+ entity_key,
933
  cleaned_body,
934
  class_label,
935
  msg,
 
938
  )
939
 
940
  debug_log(
941
+ f"Posted/Edited (role={role}, unique_groups={unique_groups}, new_group={is_new_group}, key={entity_key}, tier={class_label}, update_like={update_like})",
942
  orig_text,
943
  )
944
 
945
+ # =========================
946
+ # Event handlers
947
+ # =========================
948
  @client.on(events.NewMessage(chats=SOURCE_CHATS))
949
  async def on_new_message(event):
950
  try:
951
+ # map chat id ke role saat pertama kali terlihat
952
+ cid = abs(event.chat_id)
953
+ if cid not in chat_roles:
954
+ # naive: jika id ada di CORE_CHATS maka "core", else "support"
955
+ # (Telethon id != url, tapi kita bisa isi mapping manual jika perlu)
956
+ # Default ke "support" dulu:
957
+ chat_roles[cid] = "support"
958
+ await process_message(event.message, source_chat_id=cid)
959
  except Exception as e:
960
  print(f"Process error di chat {event.chat_id}: {e}")
961
 
 
962
  @client.on(events.NewMessage(chats=(LEADERBOARD_GROUP,)))
963
  async def on_leaderboard_reply(event):
964
  try:
 
987
  if not _is_true_leaderboard(text):
988
  return
989
 
990
+ # cooldown anti-spam
991
  global _last_lb_hash, _last_lb_ts
992
+ try:
993
+ _last_lb_hash
994
+ except NameError:
995
+ _last_lb_hash = None
996
+ _last_lb_ts = None
997
+
998
  h = _hash_text_1line(_normalize_lb_for_hash(text))
999
+ nowt = asyncio.get_event_loop().time()
1000
+ if _last_lb_hash == h and _last_lb_ts is not None and (nowt - _last_lb_ts) < LEADERBOARD_COOLDOWN_SEC:
1001
  return
1002
  _last_lb_hash = h
1003
+ _last_lb_ts = nowt
1004
 
1005
  await send_as_is(msg)
1006
  debug_log("Forward Leaderboard", text[:120])
1007
  except Exception as e:
1008
  debug_log("LB forward error", str(e))
1009
 
1010
+ # =========================
1011
+ # Scheduler: /lb acak
1012
+ # =========================
1013
  async def periodic_lb_trigger():
 
1014
  try:
1015
  lb_ent = await client.get_entity(LEADERBOARD_GROUP)
1016
  except Exception as e:
 
1031
 
1032
  await client.send_message(lb_ent, LB_TRIGGER)
1033
  print("[LB-SCHED] /lb sent")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  except Exception as e:
1035
+ print("[LB-SCHED] error:", e)
1036
+ await asyncio.sleep(60)
1037
 
1038
+ # =========================
1039
+ # Main
1040
+ # =========================
1041
+ async def main():
1042
  await client.start()
1043
+ # (opsional) mulai scheduler leaderboard
 
 
 
 
 
 
 
1044
  try:
1045
+ asyncio.create_task(periodic_lb_trigger())
 
 
1046
  except Exception as e:
1047
+ print("[LB-SCHED] not started:", e)
1048
+ print("INFO: Application startup complete.")
1049
+ print("INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  await client.run_until_disconnected()
1051
 
 
1052
  if __name__ == "__main__":
1053
+ asyncio.run(main())