Spaces:
Sleeping
Sleeping
Update botsignal.py
Browse files- 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 |
-
#
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
#
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
#
|
| 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
|
| 203 |
-
|
| 204 |
-
try:
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 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)
|
| 215 |
-
recent_entity_keys: deque[str] = deque(maxlen=DEDUP_BUFFER_SIZE)
|
| 216 |
|
| 217 |
-
#
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
# ---
|
| 308 |
-
CA_SOL_RE = re.compile(r"\b[1-9A-HJ-NP-Za-km-z]{32,48}\b") # Solana
|
| 309 |
-
CA_EVM_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b")
|
| 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 |
-
#
|
| 370 |
-
|
| 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
|
| 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 |
-
#
|
|
|
|
| 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"\
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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 |
-
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
|
| 614 |
-
|
| 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 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 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(
|
| 679 |
try:
|
| 680 |
-
if getattr(
|
| 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(
|
| 686 |
if doc:
|
| 687 |
-
data = await client.download_media(
|
| 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(
|
| 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(
|
| 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)
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 788 |
if not update_like:
|
| 789 |
return
|
| 790 |
-
|
| 791 |
-
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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(
|
| 1018 |
|
| 1019 |
-
#
|
| 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
|
| 1022 |
return
|
| 1023 |
|
| 1024 |
-
#
|
| 1025 |
if role != "core":
|
| 1026 |
-
core_u, sup_u = _unique_counts_by_role(
|
| 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 |
-
#
|
| 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
|
| 1045 |
-
|
| 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 |
-
|
| 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={
|
| 1067 |
orig_text,
|
| 1068 |
)
|
| 1069 |
|
| 1070 |
-
|
| 1071 |
-
#
|
|
|
|
| 1072 |
@client.on(events.NewMessage(chats=SOURCE_CHATS))
|
| 1073 |
async def on_new_message(event):
|
| 1074 |
try:
|
| 1075 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1111 |
-
if _last_lb_hash == h and _last_lb_ts is not None and (
|
| 1112 |
return
|
| 1113 |
_last_lb_hash = h
|
| 1114 |
-
_last_lb_ts =
|
| 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 |
-
#
|
|
|
|
| 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(
|
| 1163 |
-
|
| 1164 |
|
| 1165 |
-
|
| 1166 |
-
|
|
|
|
|
|
|
| 1167 |
await client.start()
|
| 1168 |
-
|
| 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 |
-
|
| 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(
|
| 1182 |
-
|
| 1183 |
-
|
| 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(
|
|
|
|
| 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())
|