# summary_daily.py — Daily summary + guard loop ala /lb (HF Spaces friendly) import os import time import asyncio import sqlite3 from collections import defaultdict from datetime import datetime, timezone, timedelta, date from zoneinfo import ZoneInfo # ========================= # ENV / DB # ========================= DB_PATH = os.environ.get("BOTSIGNAL_DB", "/tmp/botsignal.db") OUT_DIR = os.environ.get("SUMMARY_OUT_DIR", "summaries") # optional, not used by default JAKARTA_TZ = ZoneInfo("Asia/Jakarta") SUMMARY_CHAT_ID_ENV = os.environ.get("SUMMARY_CHAT_ID") # opsional; fallback ke TARGET_CHANNEL def _db(): conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA journal_mode=WAL;") return conn def _ensure_tables(): """ Tabel summary_*: - summary_calls: log post awal (diisi oleh botsignal saat pertama kali posting CA) - summary_milestones: log milestone reply (diisi oleh autotrack) - summary_outcomes: penanda lose (delete) """ conn = _db() conn.executescript(""" CREATE TABLE IF NOT EXISTS summary_calls ( keyword TEXT PRIMARY KEY, posted_at INTEGER NOT NULL, tier TEXT NOT NULL, msg_id INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS summary_milestones ( keyword TEXT NOT NULL, reply_msg_id INTEGER, multiple REAL, replied_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS summary_outcomes ( keyword TEXT PRIMARY KEY, is_deleted INTEGER DEFAULT 0 ); """) conn.commit() conn.close() # ========================= # Helpers # ========================= def _day_bounds_utc(d: date): # [00:00, 23:59:59] UTC untuk tanggal d sod = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) eod = datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc) return int(sod.timestamp()), int(eod.timestamp()) def _fmt(num): return f"{num:,}".replace(",", ".") # ========================= # Core summary compute # ========================= def compute_summary_for_date(d: date): _ensure_tables() sod, eod = _day_bounds_utc(d) conn = _db(); cur = conn.cursor() # Semua post awal selama hari tsb (UTC) cur.execute(""" SELECT keyword, tier, posted_at, msg_id FROM summary_calls WHERE posted_at BETWEEN ? AND ? """, (sod, eod)) rows = cur.fetchall() total_calls = len(rows) calls = {r[0]: {"tier": r[1], "posted_at": r[2], "msg_id": r[3]} for r in rows} # Max multiple per keyword (hanya reply di window hari tsb) max_mult_per_kw = {} have_reply = set() if calls: q = ",".join("?" for _ in calls) cur.execute(f""" SELECT keyword, MAX(multiple) FROM summary_milestones WHERE keyword IN ({q}) AND replied_at BETWEEN ? AND ? GROUP BY keyword """, (*calls.keys(), sod, eod)) for kw, mx in cur.fetchall(): if mx is not None: have_reply.add(kw) max_mult_per_kw[kw] = mx # Delete mark (lose) deleted = set() if calls: q = ",".join("?" for _ in calls) cur.execute(f""" SELECT keyword FROM summary_outcomes WHERE keyword IN ({q}) AND is_deleted = 1 """, (*calls.keys(),)) for (kw,) in cur.fetchall(): deleted.add(kw) conn.close() wins = have_reply loses = (deleted | (set(calls.keys()) - have_reply)) calls_per_tier = defaultdict(int) for _, meta in calls.items(): calls_per_tier[meta["tier"]] += 1 win_per_tier = defaultdict(int) lose_per_tier = defaultdict(int) for kw, meta in calls.items(): if kw in wins: win_per_tier[meta["tier"]] += 1 if kw in loses: lose_per_tier[meta["tier"]] += 1 top_kw, top_mult = None, None for kw, mult in max_mult_per_kw.items(): if top_mult is None or mult > top_mult: top_kw, top_mult = kw, mult return { "total_calls": total_calls, "win_rate": (len(wins) / total_calls) * 100 if total_calls else 0.0, "calls_per_tier": dict(sorted(calls_per_tier.items())), "total_win": len(wins), "total_lose": len(loses), "win_per_tier": dict(sorted(win_per_tier.items())), "lose_per_tier": dict(sorted(lose_per_tier.items())), "top_call": {"keyword": top_kw, "max_multiple": top_mult} if top_kw else None, } def render_telegram_html(summary: dict, label_date: str) -> str: # HTML (pakai parse_mode='html') lines = [] lines.append(f"Daily Summary — {label_date}") lines.append("") lines.append(f"Total Call: {_fmt(summary['total_calls'])}") lines.append(f"Win Rate: {summary['win_rate']:.2f}%") lines.append("") lines.append("Total Call per Tier") if summary["calls_per_tier"]: for tier, cnt in summary["calls_per_tier"].items(): lines.append(f"• {tier}: {_fmt(cnt)}") else: lines.append("• (kosong)") lines.append("") lines.append("Total Win & Lose") lines.append(f"• Win: {_fmt(summary['total_win'])}") lines.append(f"• Lose: {_fmt(summary['total_lose'])}") lines.append("") lines.append("Detail per Tier") lines.append("Win") if summary["win_per_tier"]: for tier, cnt in summary["win_per_tier"].items(): lines.append(f"• {tier}: {_fmt(cnt)}") else: lines.append("• (kosong)") lines.append("Lose") if summary["lose_per_tier"]: for tier, cnt in summary["lose_per_tier"].items(): lines.append(f"• {tier}: {_fmt(cnt)}") else: lines.append("• (kosong)") lines.append("") lines.append("Top Call (Max Reply Multiple)") if summary["top_call"] and summary["top_call"]["keyword"]: lines.append(f"• CA: {summary['top_call']['keyword']}") lines.append(f"• Max: {summary['top_call']['max_multiple']}×") else: lines.append("• (belum ada reply)") return "\n".join(lines) # ========================= # Guard loop ala /lb (idempotent) # ========================= def _sent_db(): conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA journal_mode=WAL;") return conn def _ensure_sent_table(): conn = _sent_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS summary_sent ( day TEXT PRIMARY KEY, -- YYYY-MM-DD (lokal WIB) sent_at INTEGER NOT NULL -- epoch UTC ); """) conn.commit(); conn.close() def _is_sent(day_iso: str) -> bool: try: conn = _sent_db(); cur = conn.cursor() cur.execute("SELECT 1 FROM summary_sent WHERE day = ?", (day_iso,)) ok = cur.fetchone() is not None conn.close(); return ok except Exception: return False def _mark_sent(day_iso: str): conn = _sent_db() conn.execute( "INSERT OR REPLACE INTO summary_sent(day, sent_at) VALUES(?, ?)", (day_iso, int(time.time())) ) conn.commit(); conn.close() def _yesterday_local(): now_local = datetime.now(JAKARTA_TZ) return (now_local.date() - timedelta(days=1)), now_local async def _maybe_send_daily_summary_once(client, fallback_chat: str): """ Kirim rekap H-1 sekali/hari setelah >= 06:00 WIB (lokal). Idempotent via tabel summary_sent. """ day, now_local = _yesterday_local() if now_local.hour < 6: return # belum waktunya day_iso = day.isoformat() if _is_sent(day_iso): return # sudah terkirim # Tujuan kirim target_chat = SUMMARY_CHAT_ID_ENV or os.environ.get("TARGET_CHANNEL", fallback_chat) # Hitung & kirim data = compute_summary_for_date(day) html_text = render_telegram_html(data, day_iso) await client.send_message(target_chat, html_text, parse_mode="html") _mark_sent(day_iso) print(f"[SUMMARY] sent for {day_iso} → {target_chat}") async def start_summary_guard(client, *, interval_sec: int = 900, fallback_chat: str = "@MidasTouchsignalll"): """ Panggil sekali saat startup app (mis. dari servr.py). Loop ringan cek tiap 15 menit—mirip /lb periodic. Cocok untuk HF Spaces yang sleep: saat bangun setelah 06:00, dia kirim H-1 sekali. """ _ensure_sent_table() while True: try: await _maybe_send_daily_summary_once(client, fallback_chat) except Exception as e: print(f"[SUMMARY] guard error: {e}") await asyncio.sleep(interval_sec) # ========================= # Optional: CLI sekali jalan (kirim 'hari ini') # ========================= if __name__ == "__main__": # Kirim summary untuk 'hari ini' (UTC) ke TARGET_CHANNEL — hanya untuk tes manual import asyncio as _a from telethon import TelegramClient from telethon.sessions import StringSession API_ID = int(os.environ.get("API_ID", "0")) API_HASH = os.environ.get("API_HASH", "") STRING_SESSION = os.environ.get("STRING_SESSION", "") TARGET_CHANNEL = os.environ.get("TARGET_CHANNEL", "@MidasTouchsignalll") # fallback _summary = compute_summary_for_date(date.today()) _html = render_telegram_html(_summary, date.today().strftime("%Y-%m-%d")) async def _main(): async with TelegramClient(StringSession(STRING_SESSION), API_ID, API_HASH) as c: await c.send_message(SUMMARY_CHAT_ID_ENV or TARGET_CHANNEL, _html, parse_mode="html") print("[SUMMARY] posted to Telegram") _a.run(_main())