From 15a8d8f2129a9ff393f900cf70b10cbf70d8e1d9 Mon Sep 17 00:00:00 2001 From: Hooman Maddah Date: Fri, 10 Oct 2025 13:50:51 +0330 Subject: [PATCH] fix: defer bot token requirement --- .env.example | 4 +- .github/workflows/subscriber-listener.yml | 6 +- .github/workflows/xrpbot.yml | 4 +- README.md | 3 +- listen_start.py | 83 +++++++++++++++++------ pytest.ini | 4 ++ requirements.txt | 2 +- send_test.py | 4 +- sitecustomize.py | 15 ++++ tests/test_ci_entry.py | 1 + tests/test_listen_start.py | 1 + tests/test_schedule_jobs.py | 1 + tests/test_trigger_xrp_bot.py | 1 + trigger_xrp_bot.backup.py | 7 +- trigger_xrp_bot.py | 20 ++++-- 15 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 pytest.ini create mode 100644 sitecustomize.py diff --git a/.env.example b/.env.example index 59062ac..be46ba1 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # Copy this file to .env and fill in your own secrets. -TELEGRAM_BOT_TOKEN=your-telegram-bot-token +BOT_TOKEN=your-telegram-bot-token +# Legacy fallback (optional): +# TELEGRAM_BOT_TOKEN=your-telegram-bot-token # optional when using subscribers.json. Keep it for fallback/sanity tests. TELEGRAM_CHAT_ID=your-target-chat-id CRYPTOCOMPARE_API_KEY=optional-cryptocompare-api-key diff --git a/.github/workflows/subscriber-listener.yml b/.github/workflows/subscriber-listener.yml index c9d0cad..dbc79ee 100644 --- a/.github/workflows/subscriber-listener.yml +++ b/.github/workflows/subscriber-listener.yml @@ -21,12 +21,12 @@ jobs: with: python-version: '3.12' - - run: pip install requests + - run: pip install -r requirements.txt - name: Run listener to capture /start env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - run: python listen_updates.py + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} + run: python -m src.signal_bot.ci_entry --mode prehandle - name: Commit & push if changed run: | diff --git a/.github/workflows/xrpbot.yml b/.github/workflows/xrpbot.yml index 2e91af8..c2867c4 100644 --- a/.github/workflows/xrpbot.yml +++ b/.github/workflows/xrpbot.yml @@ -25,7 +25,7 @@ jobs: - name: Run bot env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + BOT_TOKEN: ${{ secrets.BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} CRYPTOCOMPARE_API_KEY: ${{ secrets.CRYPTOCOMPARE_API_KEY }} - run: python trigger_xrp_bot.py + run: python -m src.signal_bot.ci_entry --mode summary diff --git a/README.md b/README.md index ca45fd2..406c987 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ cp .env.example .env Fill in `.env` with your secrets: ``` -TELEGRAM_BOT_TOKEN=bot-token-from-botfather +BOT_TOKEN=bot-token-from-botfather +# TELEGRAM_BOT_TOKEN=bot-token-from-botfather # optional legacy fallback TELEGRAM_CHAT_ID=chat-or-channel-id (optional fallback) CRYPTOCOMPARE_API_KEY=cryptocompare-api-key (optional) # SUBSCRIBERS_DB_PATH=/absolute/path/to/subscribers.sqlite3 (optional override) diff --git a/listen_start.py b/listen_start.py index a4de70f..d6f9cde 100644 --- a/listen_start.py +++ b/listen_start.py @@ -45,10 +45,36 @@ class TimedOut(Exception): load_dotenv(candidate_path) break -TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("BOT_TOKEN") -if not TOKEN: - raise RuntimeError("TELEGRAM_BOT_TOKEN is required to read Telegram updates") -API = f"https://api.telegram.org/bot{TOKEN}" +_BOT_TOKEN_CACHE: str | None = None +_API_BASE: str | None = None + + +def _require_bot_token(*, force_refresh: bool = False) -> str: + global _BOT_TOKEN_CACHE + if force_refresh: + _BOT_TOKEN_CACHE = None + if _BOT_TOKEN_CACHE: + return _BOT_TOKEN_CACHE + token = os.getenv("BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") + if not token: + raise RuntimeError("BOT_TOKEN is required to read Telegram updates") + _BOT_TOKEN_CACHE = token + return token + + +def _get_api_base(*, force_refresh: bool = False) -> str: + global _API_BASE + if force_refresh: + _API_BASE = None + if _API_BASE: + return _API_BASE + _API_BASE = f"https://api.telegram.org/bot{_require_bot_token()}" + return _API_BASE + + +def _api_url(method: str) -> str: + return f"{_get_api_base()}/{method}" + BROADCAST_SLEEP_MS = int(os.getenv("BROADCAST_SLEEP_MS", "0")) @@ -81,10 +107,27 @@ def get_updates(self, offset: int, timeout: int, allowed_updates=None): return [_RequestsUpdate(item) for item in payload] -if TelegramBot is not None: - BOT = TelegramBot(TOKEN) -else: # pragma: no cover - fallback when dependency missing - BOT = _RequestsBot(TOKEN) +class _DeferredBot: + def __init__(self): + self._bot = None + + def _ensure(self): + if self._bot is None: + token = _require_bot_token() + if TelegramBot is not None: + self._bot = TelegramBot(token) + else: # pragma: no cover - fallback when dependency missing + self._bot = _RequestsBot(token) + return self._bot + + def get_updates(self, *args, **kwargs): + return self._ensure().get_updates(*args, **kwargs) + + def reset(self): # pragma: no cover - convenience for tests + self._bot = None + + +BOT = _DeferredBot() ROOT = Path(__file__).resolve().parent SUBS_FILE = Path(os.getenv("SUBSCRIBERS_DB_PATH") or os.getenv("SUBSCRIBERS_PATH", str(ROOT / "subscribers.sqlite3")) @@ -386,7 +429,7 @@ def _send_stars_invoice(state: dict, chat_id: int | str, amount: int) -> bool: } try: - response = requests.post(f"{API}/sendInvoice", json=invoice, timeout=20) + response = requests.post(_api_url("sendInvoice"), json=invoice, timeout=20) response.raise_for_status() except requests.HTTPError as exc: _handle_invoice_error(chat_id, exc) @@ -429,7 +472,7 @@ def handle_donate_stars_start(state: dict, chat_id: int | str) -> bool: } try: - response = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + response = requests.post(_api_url("sendMessage"), json=payload, timeout=20) response.raise_for_status() except Exception as exc: # pragma: no cover - network failure print(f"Failed to send donation options to {chat_id}: {exc}") @@ -479,7 +522,7 @@ def handle_donate_custom_text(state: dict, chat_id: int | str, raw_text: str) -> def handle_pre_checkout_query(query: dict) -> None: payload = {"pre_checkout_query_id": query.get("id"), "ok": True} try: - response = requests.post(f"{API}/answerPreCheckoutQuery", json=payload, timeout=20) + response = requests.post(_api_url("answerPreCheckoutQuery"), json=payload, timeout=20) response.raise_for_status() except Exception as exc: # pragma: no cover - best effort acknowledgement print(f"Failed to answer pre-checkout query {query.get('id')}: {exc}") @@ -574,7 +617,7 @@ def handle_refund_request(chat_id: int | str, raw_text: str) -> None: charge_id = parts[1].strip() payload = {"user_id": chat_id, "telegram_payment_charge_id": charge_id} try: - response = requests.post(f"{API}/refundStarPayment", json=payload, timeout=20) + response = requests.post(_api_url("refundStarPayment"), json=payload, timeout=20) response.raise_for_status() except Exception as exc: # pragma: no cover - network failure print(f"Refund request failed for {chat_id}: {exc}") @@ -610,7 +653,7 @@ def send_start_prompt(chat_id: int | str, already_registered: bool = False) -> N "one_time_keyboard": True, } payload = {"chat_id": chat_id, "text": text, "reply_markup": keyboard} - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -624,7 +667,7 @@ def send_contact_confirmation(chat_id: int | str) -> None: "text": text, "reply_markup": {"remove_keyboard": True}, } - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -649,7 +692,7 @@ def send_menu(chat_id: int | str, *, prepend_text: str | None = None) -> None: "text": "\n".join(line for line in lines if line is not None), "reply_markup": MENU_MARKUP, } - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -658,7 +701,7 @@ def send_unsubscribe_confirmation(chat_id: int | str) -> None: "اشتراک شما غیرفعال شد. برای فعال‌سازی دوباره، هر زمان /start را بفرستید و شماره خود را ارسال کنید." ) payload = {"chat_id": chat_id, "text": text, "reply_markup": {"remove_keyboard": True}} - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -666,7 +709,7 @@ def answer_callback(callback_id: str, *, text: str | None = None) -> None: payload = {"callback_query_id": callback_id} if text: payload["text"] = text - resp = requests.post(f"{API}/answerCallbackQuery", json=payload, timeout=20) + resp = requests.post(_api_url("answerCallbackQuery"), json=payload, timeout=20) resp.raise_for_status() @@ -733,7 +776,7 @@ def _send_ambiguous_options(chat_id: int | str, candidates: list[ResolutionCandi "text": "چند گزینه مشابه یافت شد، لطفاً انتخاب کنید:", "reply_markup": keyboard, } - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -803,7 +846,7 @@ def _send_remove_page(chat_id: int | str, pairs: list[str], page: int) -> None: + (f"\nصفحه {page_index + 1} از {total_pages}" if total_pages > 1 else ""), "reply_markup": {"inline_keyboard": keyboard}, } - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() @@ -861,7 +904,7 @@ def _send_remove_confirm(chat_id: int | str, pair: str) -> None: "text": f"{pair}\nحذف شود؟ / Remove?", "reply_markup": keyboard, } - resp = requests.post(f"{API}/sendMessage", json=payload, timeout=20) + resp = requests.post(_api_url("sendMessage"), json=payload, timeout=20) resp.raise_for_status() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..fe36924 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +norecursedirs = lib bin +python_files = test_*.py +pythonpath = . diff --git a/requirements.txt b/requirements.txt index 995ffce..d0416ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ requests pandas numpy -python-dotenv +python-dotenv>=1.0.1 tzdata APScheduler SQLAlchemy>=1.4 diff --git a/send_test.py b/send_test.py index dedcaa5..7bcc122 100644 --- a/send_test.py +++ b/send_test.py @@ -10,11 +10,11 @@ if candidate_path.exists(): load_dotenv(candidate_path) break -TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +TOKEN = os.getenv("BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") if not TOKEN or not CHAT_ID: - raise RuntimeError("Both TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set for send_test.py") + raise RuntimeError("Both BOT_TOKEN and TELEGRAM_CHAT_ID must be set for send_test.py") msg = "سلام هومن! ✅ تست ارسال پیام از ربات انجام شد." url = f"https://api.telegram.org/bot{TOKEN}/sendMessage" diff --git a/sitecustomize.py b/sitecustomize.py new file mode 100644 index 0000000..00b702c --- /dev/null +++ b/sitecustomize.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent +_LEGACY_SITE = _REPO_ROOT / "lib" / "python3.13" / "site-packages" + +if _LEGACY_SITE.exists(): + legacy_resolved = _LEGACY_SITE.resolve() + sys.path = [p for p in sys.path if Path(p).resolve() != legacy_resolved] + +repo_str = str(_REPO_ROOT) +if repo_str not in sys.path: + sys.path.insert(0, repo_str) diff --git a/tests/test_ci_entry.py b/tests/test_ci_entry.py index 2fe978f..1aabf53 100644 --- a/tests/test_ci_entry.py +++ b/tests/test_ci_entry.py @@ -2,6 +2,7 @@ import unittest from unittest import mock +os.environ.setdefault("BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token") from trigger_xrp_bot import generate_snapshot_payload # noqa: E402 diff --git a/tests/test_listen_start.py b/tests/test_listen_start.py index 5f44cf7..3be99c9 100644 --- a/tests/test_listen_start.py +++ b/tests/test_listen_start.py @@ -7,6 +7,7 @@ from pathlib import Path from unittest import mock +os.environ.setdefault("BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token") sys.path.append(str(Path(__file__).resolve().parents[1])) diff --git a/tests/test_schedule_jobs.py b/tests/test_schedule_jobs.py index 40f7c78..a568903 100644 --- a/tests/test_schedule_jobs.py +++ b/tests/test_schedule_jobs.py @@ -1,4 +1,5 @@ import os +os.environ.setdefault("BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_CHAT_ID", "111") import unittest diff --git a/tests/test_trigger_xrp_bot.py b/tests/test_trigger_xrp_bot.py index 9c26512..8f4448b 100644 --- a/tests/test_trigger_xrp_bot.py +++ b/tests/test_trigger_xrp_bot.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest import mock +os.environ.setdefault("BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token") os.environ.setdefault("TELEGRAM_CHAT_ID", "111") diff --git a/trigger_xrp_bot.backup.py b/trigger_xrp_bot.backup.py index d6b1c6e..c5d1290 100644 --- a/trigger_xrp_bot.backup.py +++ b/trigger_xrp_bot.backup.py @@ -7,9 +7,12 @@ # ====== تنظیمات از .env ====== load_dotenv(os.path.expanduser("~/xrpbot/.env")) -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +BOT_TOKEN = os.getenv("BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") +if not BOT_TOKEN: + raise RuntimeError("BOT_TOKEN is required (set in .env or environment)") + SEND_ONLY_ON_TRIGGER = False # اگر True فقط موقع سیگنال "خرید" پیام می‌دهد SYMBOL = "XRPUSDT" @@ -71,7 +74,7 @@ def bullish_divergence(price, rsi_series): def tehran_now(): return datetime.now(ZoneInfo("Asia/Tehran")).strftime("%Y-%m-%d %H:%M:%S") def send_telegram(text): - url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" payload = {"chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "HTML", "disable_web_page_preview": True} requests.post(url, json=payload, timeout=20) diff --git a/trigger_xrp_bot.py b/trigger_xrp_bot.py index 4d7e30d..55c8da2 100644 --- a/trigger_xrp_bot.py +++ b/trigger_xrp_bot.py @@ -50,14 +50,25 @@ if _candidate_path.exists(): load_dotenv(_candidate_path) break -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("BOT_TOKEN") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID") CRYPTOCOMPARE_API_KEY = os.getenv("CRYPTOCOMPARE_API_KEY") BROADCAST_SLEEP_MS = int(os.getenv("BROADCAST_SLEEP_MS", "0")) _BACKOFF_SCHEDULE = (0.5, 1.0, 2.0) -if not TELEGRAM_BOT_TOKEN: - raise RuntimeError("TELEGRAM_BOT_TOKEN is required (set in .env or environment)") +_BOT_TOKEN_CACHE: str | None = None + + +def _require_bot_token(*, force_refresh: bool = False) -> str: + global _BOT_TOKEN_CACHE + if force_refresh: + _BOT_TOKEN_CACHE = None + if _BOT_TOKEN_CACHE: + return _BOT_TOKEN_CACHE + token = os.getenv("BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") + if not token: + raise RuntimeError("BOT_TOKEN is required (set in .env or environment)") + _BOT_TOKEN_CACHE = token + return token def resolve_chat_ids(subscribers: list[dict[str, str]], fallback_chat_id: str | None) -> list[str]: @@ -281,7 +292,8 @@ def tehran_now(): return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S") def send_telegram(chat_id: str, text: str): - url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" + token = _require_bot_token() + url = f"https://api.telegram.org/bot{token}/sendMessage" payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML", "disable_web_page_preview": True} for attempt, backoff in enumerate(_BACKOFF_SCHEDULE, start=1):