diff --git a/.env.sample b/.env.sample index deb192e19..3df4dfdb6 100644 --- a/.env.sample +++ b/.env.sample @@ -1,13 +1,53 @@ -# Don't use quotes( " and ' ) +# Ultroid environment sample +# Copy to .env and fill values. Don't use quotes ( " and ' ). +# Guided setup: python -m pyUltroid setup +# Health check: python -m pyUltroid doctor +# +# Hosted (Heroku / Railway / Fly / …): set the same keys as config vars +# in the dashboard — do not rely on interactive setup there. +# ── Required ────────────────────────────────────────────── +# From https://my.telegram.org (your own app — no shared defaults) API_ID= API_HASH= -SESSION= - -# [OPTIONAL] +# Telethon/Pyrogram string session (bash sessiongen) +SESSION= +# ── Database (pick one) ─────────────────────────────────── +# Redis: host:port only — NO redis:// or https:// prefix REDIS_URI= REDIS_PASSWORD= -LOG_CHANNEL= -BOT_TOKEN= + +# Railway-style Redis (alternative to REDIS_URI) +# REDISHOST= +# REDISPORT= +# REDISPASSWORD= + +# MongoDB +# MONGO_URI=mongodb+srv://user:pass@cluster/... + +# PostgreSQL +# DATABASE_URL=postgres://user:pass@host:5432/dbname + +# ── Assistant / logging ─────────────────────────────────── +# BOT_TOKEN=123456:ABC... # optional; autopilot can create one +# LOG_CHANNEL=-100xxxxxxxxxx # optional; autopilot can create one + +# ── Features ────────────────────────────────────────────── +# ADDONS=False +# VCBOT=False +# VC_SESSION= + +# ── Hosted extras ───────────────────────────────────────── +# HEROKU_API= +# HEROKU_APP_NAME= +# TGDB_URL= + +# ── QoL / safety toggles (hosted-safe) ──────────────────── +# ULTROID_AUTO_PIP=1 # runtime pip fallback (default on). Set 0 on locked hosts. +# ULTROID_STRICT_CONFIG=0 # set 1 to require a remote DB at boot +# SKIP_AUTOPILOT=False # set True + provide LOG_CHANNEL yourself +# SKIP_AUTOBOT=False # set True + provide BOT_TOKEN yourself +# SKIP_AUTOJOIN=False # skip joining @TheUltroid on start +# SKIP_ASSISTANT_CUSTOMIZE=False # skip BotFather profile customisation diff --git a/.gitignore b/.gitignore index ce5d8ae1e..ff77c05e9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,11 @@ *.session-journal *.session build -test* *.mp3 *.webm *.webp *.mp4 *.tgs -*.txt /*.jpg /*.png /*.mp4 @@ -24,6 +22,7 @@ addons/ vcbot/ __pycache__/ venv/ +.venv/ node_modules/ glitch_me/ src/glitch-me @@ -44,4 +43,4 @@ bin-release/ *.raw # fly.io configs -fly.toml \ No newline at end of file +fly.toml diff --git a/README.md b/README.md index 6c2c453c3..e1c5cb497 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,13 @@ Get the [Necessary Variables](#Necessary-Variables) and then click the button be - Create a virtual env: `virtualenv -p /usr/bin/python3 venv` `. ./venv/bin/activate` -- Install the requirements: -`pip(3) install -U -r re*/st*/optional-requirements.txt` -`pip(3) install -U -r requirements.txt` +- **Recommended:** guided setup (writes `.env`, installs deps by profile): + `python -m pyUltroid setup` + Health check anytime: `python -m pyUltroid doctor` +- Or install requirements manually: +`pip install -U -r requirements.txt` +`pip install -U -r requirements-db-redis.txt` # or requirements-db-mongo.txt / requirements-db-postgres.txt +`pip install -U -r requirements-full.txt` # optional plugin extras - Generate your `SESSION`: - For Linux users: `bash sessiongen` @@ -91,17 +95,23 @@ Get the [Necessary Variables](#Necessary-Variables) and then click the button be --- ## Necessary Variables +- `API_ID` / `API_HASH` - from [my.telegram.org](https://my.telegram.org) (**required**; shared Telegram Android defaults are rejected). - `SESSION` - SessionString for your accounts login session. Get it from [here](#Session-String) One of the following database: - For **Redis** (tutorial [here](./resources/extras/redistut.md)) - - `REDIS_URI` - Redis endpoint URL, from [redislabs](http://redislabs.com/). + - `REDIS_URI` - Redis `host:port` only (no `redis://` / `https://`), from [redislabs](http://redislabs.com/) or similar. - `REDIS_PASSWORD` - Redis endpoint Password, from [redislabs](http://redislabs.com/). - For **MONGODB** - `MONGO_URI` - Get it from [mongodb](https://mongodb.com/atlas). - For **SQLDB** - `DATABASE_URL`- Get it from [elephantsql](https://elephantsql.com). +Optional safety toggles (see `.env.sample`): +- `ULTROID_AUTO_PIP` — runtime pip fallback for missing DB drivers (`1` default, set `0` on locked hosts). +- `SKIP_AUTOPILOT` / `SKIP_AUTOBOT` / `SKIP_AUTOJOIN` / `SKIP_ASSISTANT_CUSTOMIZE` — opt out of startup side-effects. +- `ULTROID_STRICT_CONFIG=1` — require a remote DB at boot. + ## Session String Different ways to get your `SESSION`: * [![Run on Repl.it](https://replit.com/badge/github/TeamUltroid/Ultroid)](https://replit.com/@TeamUltroid/UltroidStringSession) diff --git a/app.json b/app.json index d192badba..14cbf85ea 100644 --- a/app.json +++ b/app.json @@ -15,26 +15,49 @@ "stack": "container", "env": { "API_ID": { - "description": "You api id, from my.telegram.org or @ScrapperRoBot.", - "value": "", - "required": false + "description": "Your api id from https://my.telegram.org (required — no shared defaults).", + "value": "", + "required": true }, "API_HASH": { - "description": "You api hash, from my.telegram.org or @ScrapperRoBot.", + "description": "Your api hash from https://my.telegram.org (required — no shared defaults).", "value": "", - "required": false + "required": true }, "SESSION": { "description": "Session String (telethon or pyrogram) for your telegram user account. The userbot will NOT work without a session string!!", - "value": "" + "value": "", + "required": true }, "REDIS_URI": { - "description": "Redis endpoint URL, from redislabs.com", - "value": "" + "description": "Redis host:port only (no redis://). Example: redis-123.example.com:12345", + "value": "", + "required": false }, "REDIS_PASSWORD": { - "description": "Redis endpoint password, from redislabs.com", - "value": "" + "description": "Redis password from your provider.", + "value": "", + "required": false + }, + "MONGO_URI": { + "description": "Optional MongoDB URI (mongodb:// or mongodb+srv://). Use instead of Redis.", + "value": "", + "required": false + }, + "DATABASE_URL": { + "description": "Optional Postgres URI (postgres://...). Use instead of Redis/Mongo.", + "value": "", + "required": false + }, + "BOT_TOKEN": { + "description": "Optional assistant bot token from @BotFather. Autopilot can create one if omitted.", + "value": "", + "required": false + }, + "LOG_CHANNEL": { + "description": "Optional log channel/group id. Autopilot can create one if omitted.", + "value": "", + "required": false }, "HEROKU_API": { "description": "Heroku API token. Mandatory for Heroku Deploy...", @@ -45,6 +68,31 @@ "description": "Name of your Heroku app, given in the first blank on this page. To be added if deploying to heroku ONLY.", "value": "", "required": false + }, + "ULTROID_AUTO_PIP": { + "description": "Allow runtime pip install of missing DB drivers (1/0). Default 1 for back-compat; set 0 on locked hosts.", + "value": "1", + "required": false + }, + "SKIP_AUTOJOIN": { + "description": "Set True to skip auto-joining @TheUltroid on startup.", + "value": "False", + "required": false + }, + "SKIP_AUTOBOT": { + "description": "Set True to skip BotFather bot creation (requires BOT_TOKEN).", + "value": "False", + "required": false + }, + "SKIP_AUTOPILOT": { + "description": "Set True to skip auto log-channel creation (requires LOG_CHANNEL).", + "value": "False", + "required": false + }, + "SKIP_ASSISTANT_CUSTOMIZE": { + "description": "Set True to skip assistant profile customisation at @BotFather.", + "value": "False", + "required": false } }, "formation": { diff --git a/assistant/initial.py b/assistant/initial.py index abf334b28..349854eae 100644 --- a/assistant/initial.py +++ b/assistant/initial.py @@ -44,13 +44,52 @@ } +def _onboard_text(): + lang = udB.get_key("language") or "en" + hndlr = udB.get_key("HNDLR") or "." + pmpermit = "on" if udB.get_key("PMLOG") or udB.get_key("PMREQUEST") else "default" + addons = "on" if udB.get_key("ADDONS") else "off" + autojoin = "skipped" if udB.get_key("SKIP_AUTOJOIN") else "allowed" + return f"""⚙️ **Quick setup** + +Current values (tap to toggle / cycle): + +• **Language**: `{lang}` +• **Handler (HNDLR)**: `{hndlr}` +• **Addons**: `{addons}` +• **Auto-join @TheUltroid**: `{autojoin}` + +Use `{hndlr}setdb KEY value` for advanced keys. +When finished, tap **Done**.""" + + +def _onboard_buttons(): + return [ + [ + Button.inline("Language", "onboard_lang"), + Button.inline("Handler", "onboard_hndlr"), + ], + [ + Button.inline("Toggle Addons", "onboard_addons"), + Button.inline("Toggle Auto-join", "onboard_join"), + ], + [ + Button.inline("PM Permit tip", "onboard_pm"), + Button.inline("Done ✓", "onboard_done"), + ], + ] + + @callback(re.compile("initft_(\\d+)")) async def init_depl(e): CURRENT = int(e.data_match.group(1)) if CURRENT == 5: return await e.edit( STRINGS[5], - buttons=Button.inline("<< Back", "initbk_4"), + buttons=[ + Button.inline("<< Back", "initbk_4"), + Button.inline("⚙️ Quick setup", "onboard_menu"), + ], link_preview=False, ) @@ -70,7 +109,10 @@ async def ineiq(e): if CURRENT == 1: return await e.edit( STRINGS[1], - buttons=Button.inline("Start Back >>", "initft_2"), + buttons=[ + Button.inline("Start Back >>", "initft_2"), + Button.inline("⚙️ Quick setup", "onboard_menu"), + ], link_preview=False, ) @@ -82,3 +124,82 @@ async def ineiq(e): ], link_preview=False, ) + + +@callback("onboard_menu") +async def onboard_menu(e): + await e.edit(_onboard_text(), buttons=_onboard_buttons(), link_preview=False) + + +@callback("onboard_lang") +async def onboard_lang(e): + # Cycle a small set of common languages present under strings/ + langs = ["en", "hi", "ar", "id", "pt", "es", "ru", "tr"] + cur = udB.get_key("language") or "en" + try: + nxt = langs[(langs.index(cur) + 1) % len(langs)] + except ValueError: + nxt = "en" + udB.set_key("language", nxt) + await e.answer(f"Language → {nxt}") + await e.edit(_onboard_text(), buttons=_onboard_buttons(), link_preview=False) + + +@callback("onboard_hndlr") +async def onboard_hndlr(e): + options = [".", "!", "/", "?", "$"] + cur = udB.get_key("HNDLR") or "." + try: + nxt = options[(options.index(cur) + 1) % len(options)] + except ValueError: + nxt = "." + udB.set_key("HNDLR", nxt) + await e.answer(f"HNDLR → {nxt} (restart recommended)") + await e.edit(_onboard_text(), buttons=_onboard_buttons(), link_preview=False) + + +@callback("onboard_addons") +async def onboard_addons(e): + cur = bool(udB.get_key("ADDONS")) + udB.set_key("ADDONS", not cur) + await e.answer("Addons " + ("on" if not cur else "off") + " (restart to apply)") + await e.edit(_onboard_text(), buttons=_onboard_buttons(), link_preview=False) + + +@callback("onboard_join") +async def onboard_join(e): + cur = bool(udB.get_key("SKIP_AUTOJOIN")) + udB.set_key("SKIP_AUTOJOIN", not cur) + await e.answer("Auto-join " + ("skipped" if not cur else "allowed")) + await e.edit(_onboard_text(), buttons=_onboard_buttons(), link_preview=False) + + +@callback("onboard_pm") +async def onboard_pm(e): + h = udB.get_key("HNDLR") or "." + await e.answer("See tip in message", alert=False) + await e.edit( + f"""🛡 **PM Permit** + +Protect your PM with: +• `{h}setdb PMLOG True` — log private messages +• Official plugin: `{h}help pmpermit` (if loaded) + +Back to setup when ready.""", + buttons=[[Button.inline("« Back", "onboard_menu")]], + link_preview=False, + ) + + +@callback("onboard_done") +async def onboard_done(e): + udB.set_key("ONBOARD_DONE", "Done") + h = udB.get_key("HNDLR") or "." + await e.edit( + f"""✅ **Setup saved.** + +• Some toggles need a restart (`{h}restart`) to fully apply. +• Help: `{h}help` · Health: `python -m pyUltroid doctor` on the host.""", + buttons=[[Button.inline("« Intro", "initft_1")]], + link_preview=False, + ) diff --git a/installer.sh b/installer.sh index 51b43d102..1c357d103 100644 --- a/installer.sh +++ b/installer.sh @@ -176,15 +176,31 @@ misc_install() { dep_install() { echo -e "\n\nInstalling DB Requirement..." - if [ $MONGO_URI ]; then + # Prefer declarative profiles when present (P0 QoL); fall back to package names. + if [ "$MONGO_URI" ]; then echo -e " Installing MongoDB Requirements..." - pip3 install -q pymongo[srv] - elif [ $DATABASE_URL ]; then + if [ -f "$DIR/requirements-db-mongo.txt" ]; then + pip3 install -q -r "$DIR/requirements-db-mongo.txt" + else + pip3 install -q "pymongo[srv]" + fi + elif [ "$DATABASE_URL" ]; then echo -e " Installing PostgreSQL Requirements..." - pip3 install -q psycopg2-binary - elif [ $REDIS_URI ]; then + if [ -f "$DIR/requirements-db-postgres.txt" ]; then + pip3 install -q -r "$DIR/requirements-db-postgres.txt" + else + pip3 install -q psycopg2-binary + fi + elif [ "$REDIS_URI" ] || [ "$REDISHOST" ]; then echo -e " Installing Redis Requirements..." - pip3 install -q redis hiredis + if [ -f "$DIR/requirements-db-redis.txt" ]; then + pip3 install -q -r "$DIR/requirements-db-redis.txt" + else + pip3 install -q redis hiredis + fi + else + echo -e " No remote DB env detected — installing localdb fallback..." + pip3 install -q localdb.json fi } diff --git a/pyUltroid/__init__.py b/pyUltroid/__init__.py index a6d8267e6..cd8cb90f3 100644 --- a/pyUltroid/__init__.py +++ b/pyUltroid/__init__.py @@ -7,10 +7,20 @@ import os import sys -import telethonpatch +from logging import INFO, StreamHandler, basicConfig, getLogger + +# CLI subcommands (setup/doctor) must not boot the full userbot client stack. +_CLI_COMMANDS = {"setup", "doctor", "-h", "--help", "help"} +_is_cli = len(sys.argv) > 1 and sys.argv[1] in _CLI_COMMANDS + from .version import __version__ -run_as_module = __package__ in sys.argv or sys.argv[0] == "-m" +run_as_module = (not _is_cli) and ( + __package__ in sys.argv + or sys.argv[0] == "-m" + or str(sys.argv[0]).endswith("__main__.py") + or str(sys.argv[0]).endswith(os.path.join("pyUltroid", "__main__.py")) +) class ULTConfig: @@ -19,9 +29,34 @@ class ULTConfig: if run_as_module: - import time + # Validate config BEFORE heavy imports (telethon / telethonpatch / DB drivers) + # so missing API_ID/SESSION fail fast with clear messages on any host. + _boot_log = getLogger("pyUltLogs") + if not _boot_log.handlers: + basicConfig( + format="%(asctime)s | %(name)s [%(levelname)s] : %(message)s", + level=INFO, + datefmt="%m/%d/%Y, %H:%M:%S", + handlers=[StreamHandler()], + ) from .configs import Var + + # Load validator by path so we don't import startup/__init__.py (Telethon) yet. + import importlib.util + + _cv_path = os.path.join(os.path.dirname(__file__), "startup", "config_validate.py") + _cv_spec = importlib.util.spec_from_file_location( + "pyUltroid_boot_config_validate", _cv_path + ) + _cv = importlib.util.module_from_spec(_cv_spec) + _cv_spec.loader.exec_module(_cv) + _cv.validate_config_or_exit(Var, _boot_log) + + import time + + import telethonpatch # noqa: F401 + from .startup import * from .startup._database import UltroidDB from .startup.BaseClient import UltroidClient @@ -33,7 +68,7 @@ class ULTConfig: LOGS.error( "'plugins' folder not found!\nMake sure that, you are on correct path." ) - exit() + sys.exit(1) start_time = time.time() _ult_cache = {} @@ -61,10 +96,11 @@ class ULTConfig: if not udB.get_key("BOT_TOKEN"): LOGS.critical( - '"BOT_TOKEN" not Found! Please add it, in order to use "BOTMODE"' + '"BOT_TOKEN" not Found! Please add it, in order to use "BOTMODE"\n' + " → Create a bot via @BotFather and set BOT_TOKEN in config vars." ) - sys.exit() + sys.exit(1) else: ultroid_bot = UltroidClient( validate_session(Var.SESSION, LOGS), @@ -72,7 +108,15 @@ class ULTConfig: app_version=ultroid_version, device_model="Ultroid", ) - ultroid_bot.run_in_loop(autobot()) + skip_autobot = Var.SKIP_AUTOBOT or udB.get_key("SKIP_AUTOBOT") + if skip_autobot and not (udB.get_key("BOT_TOKEN") or Var.BOT_TOKEN): + LOGS.critical( + "SKIP_AUTOBOT is set but BOT_TOKEN is missing.\n" + " → Create a bot at @BotFather and set BOT_TOKEN, or unset SKIP_AUTOBOT." + ) + sys.exit(1) + if not skip_autobot: + ultroid_bot.run_in_loop(autobot()) if USER_MODE: asst = ultroid_bot @@ -99,8 +143,6 @@ class ULTConfig: DUAL_HNDLR = udB.get_key("DUAL_HNDLR") or "/" SUDO_HNDLR = udB.get_key("SUDO_HNDLR") or HNDLR else: - print("pyUltroid 2022 © TeamUltroid") - from logging import getLogger LOGS = getLogger("pyUltroid") diff --git a/pyUltroid/__main__.py b/pyUltroid/__main__.py index 82e67c856..ff075146b 100644 --- a/pyUltroid/__main__.py +++ b/pyUltroid/__main__.py @@ -5,16 +5,35 @@ # PLease read the GNU Affero General Public License in # . -from . import * +import sys + +_CLI = {"setup", "doctor", "-h", "--help", "help"} + + +def _run_cli(): + from .startup.setup_cli import main as setup_main + + raise SystemExit(setup_main(sys.argv[1:])) def main(): import os - import sys import time - from .fns.helper import bash, time_formatter, updater - from .startup.funcs import ( + # Full boot only when not a CLI command (import * must stay at function scope + # carefully — pull symbols explicitly after package init). + import pyUltroid as _pkg + from pyUltroid import ( + HOSTED_ON, + LOGS, + Var, + asst, + start_time, + udB, + ultroid_bot, + ) + from pyUltroid.fns.helper import bash, time_formatter, updater + from pyUltroid.startup.funcs import ( WasItRestart, autopilot, customize, @@ -23,7 +42,7 @@ def main(): ready, startup_stuff, ) - from .startup.loader import load_other_plugins + from pyUltroid.startup.loader import load_other_plugins try: from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -49,7 +68,18 @@ def main(): LOGS.info("Initialising...") - ultroid_bot.run_in_loop(autopilot()) + skip_autopilot = Var.SKIP_AUTOPILOT or udB.get_key("SKIP_AUTOPILOT") + if skip_autopilot: + if not (udB.get_key("LOG_CHANNEL") or Var.LOG_CHANNEL): + LOGS.critical( + "SKIP_AUTOPILOT is set but LOG_CHANNEL is missing.\n" + " → Create a private group, add your assistant, set LOG_CHANNEL to its id." + ) + sys.exit(1) + LOGS.info("SKIP_AUTOPILOT set — not auto-creating log channel.") + else: + ultroid_bot.run_in_loop(autopilot()) + ultroid_bot.loop.create_task(keep_redis_alive()) pmbot = udB.get_key("PMBOT") @@ -77,7 +107,13 @@ def main(): plugin_channels = udB.get_key("PLUGIN_CHANNEL") # Customize Ultroid Assistant... - ultroid_bot.run_in_loop(customize()) + skip_customize = Var.SKIP_ASSISTANT_CUSTOMIZE or udB.get_key( + "SKIP_ASSISTANT_CUSTOMIZE" + ) + if skip_customize: + LOGS.info("SKIP_ASSISTANT_CUSTOMIZE set — skipping BotFather customisation.") + else: + ultroid_bot.run_in_loop(customize()) # Load Addons from Plugin Channels. if plugin_channels: @@ -99,9 +135,12 @@ def main(): f"Took {time_formatter((time.time() - start_time)*1000)} to start •ULTROID•" ) LOGS.info(suc_msg) + return asst if __name__ == "__main__": - main() - - asst.run() + if len(sys.argv) > 1 and sys.argv[1] in _CLI: + _run_cli() + else: + _asst = main() + _asst.run() diff --git a/pyUltroid/configs.py b/pyUltroid/configs.py index 94d571847..0a3f5a319 100644 --- a/pyUltroid/configs.py +++ b/pyUltroid/configs.py @@ -5,6 +5,7 @@ # PLease read the GNU Affero General Public License in # . +import os import sys from decouple import config @@ -16,29 +17,43 @@ except ImportError: pass +# CLI subcommands must not be parsed as API_ID (multi_client still uses argv slots). +_CLI_COMMANDS = {"setup", "doctor", "-h", "--help", "help"} + + +def _argv_is_cli(): + return len(sys.argv) > 1 and sys.argv[1] in _CLI_COMMANDS + + +def _argv_val(index): + if _argv_is_cli(): + return None + if len(sys.argv) > index: + return sys.argv[index] + return None + + +def _as_int(value, default=None): + if value in (None, ""): + return default + try: + return int(value) + except (TypeError, ValueError): + return default + class Var: - # mandatory - API_ID = ( - int(sys.argv[1]) if len(sys.argv) > 1 else config("API_ID", default=6, cast=int) - ) - API_HASH = ( - sys.argv[2] - if len(sys.argv) > 2 - else config("API_HASH", default="eb06d4abfb49dc3eeb1aeb98ae0f581e") - ) - SESSION = sys.argv[3] if len(sys.argv) > 3 else config("SESSION", default=None) - REDIS_URI = ( - sys.argv[4] - if len(sys.argv) > 4 - else (config("REDIS_URI", default=None) or config("REDIS_URL", default=None)) - ) - REDIS_PASSWORD = ( - sys.argv[5] if len(sys.argv) > 5 else config("REDIS_PASSWORD", default=None) + # mandatory — no silent Telegram Android defaults (see config_validate) + API_ID = _as_int(_argv_val(1), default=_as_int(config("API_ID", default=None))) + API_HASH = _argv_val(2) or config("API_HASH", default=None) + SESSION = _argv_val(3) or config("SESSION", default=None) + REDIS_URI = _argv_val(4) or ( + config("REDIS_URI", default=None) or config("REDIS_URL", default=None) ) + REDIS_PASSWORD = _argv_val(5) or config("REDIS_PASSWORD", default=None) # extras BOT_TOKEN = config("BOT_TOKEN", default=None) - LOG_CHANNEL = config("LOG_CHANNEL", default=0, cast=int) + LOG_CHANNEL = _as_int(config("LOG_CHANNEL", default=0), default=0) or 0 HEROKU_APP_NAME = config("HEROKU_APP_NAME", default=None) HEROKU_API = config("HEROKU_API", default=None) VC_SESSION = config("VC_SESSION", default=None) @@ -55,3 +70,15 @@ class Var: MONGO_URI = config("MONGO_URI", default=None) # for local Telegram DB backup TGDB_URL = config("TGDB_URL", default=None) + # QoL / hosted-safe toggles + # ULTROID_AUTO_PIP: runtime pip fallback (default on for back-compat) + # ULTROID_STRICT_CONFIG: fail boot without remote DB + # SKIP_AUTOPILOT / SKIP_AUTOJOIN / SKIP_AUTOBOT: opt out of side effects + AUTO_PIP = config("ULTROID_AUTO_PIP", default=None) + STRICT_CONFIG = config("ULTROID_STRICT_CONFIG", default=False, cast=bool) + SKIP_AUTOPILOT = config("SKIP_AUTOPILOT", default=False, cast=bool) + SKIP_AUTOJOIN = config("SKIP_AUTOJOIN", default=False, cast=bool) + SKIP_AUTOBOT = config("SKIP_AUTOBOT", default=False, cast=bool) + SKIP_ASSISTANT_CUSTOMIZE = config( + "SKIP_ASSISTANT_CUSTOMIZE", default=False, cast=bool + ) diff --git a/pyUltroid/startup/_database.py b/pyUltroid/startup/_database.py index 79100fb39..747ab4046 100644 --- a/pyUltroid/startup/_database.py +++ b/pyUltroid/startup/_database.py @@ -16,35 +16,69 @@ from ..configs import Var +def _try_auto_pip(packages: str, reason: str) -> bool: + """Install packages at runtime only when ULTROID_AUTO_PIP allows it.""" + from .config_validate import auto_pip_enabled, pip_install_hint + + if not auto_pip_enabled(): + LOGS.error(pip_install_hint(packages)) + LOGS.error("Runtime auto-pip is disabled (ULTROID_AUTO_PIP=0).") + return False + LOGS.info("Installing '%s' for %s (ULTROID_AUTO_PIP).", packages, reason) + rc = os.system(f"{sys.executable} -m pip install -q {packages}") + return rc == 0 + + Redis = MongoClient = psycopg2 = Database = None if Var.REDIS_URI or Var.REDISHOST: try: from redis import Redis except ImportError: - LOGS.info("Installing 'redis' for database.") - os.system(f"{sys.executable} -m pip install -q redis hiredis") - from redis import Redis + if _try_auto_pip("redis hiredis", "database"): + from redis import Redis + else: + LOGS.critical( + "Redis configured but 'redis' is not installed.\n" + " → pip install -r requirements-db-redis.txt" + ) + sys.exit(1) elif Var.MONGO_URI: try: from pymongo import MongoClient except ImportError: - LOGS.info("Installing 'pymongo' for database.") - os.system(f"{sys.executable} -m pip install -q pymongo[srv]") - from pymongo import MongoClient + if _try_auto_pip("'pymongo[srv]'", "database"): + from pymongo import MongoClient + else: + LOGS.critical( + "MongoDB configured but 'pymongo' is not installed.\n" + " → pip install -r requirements-db-mongo.txt" + ) + sys.exit(1) elif Var.DATABASE_URL: try: import psycopg2 except ImportError: - LOGS.info("Installing 'pyscopg2' for database.") - os.system(f"{sys.executable} -m pip install -q psycopg2-binary") - import psycopg2 + if _try_auto_pip("psycopg2-binary", "database"): + import psycopg2 + else: + LOGS.critical( + "PostgreSQL configured but 'psycopg2' is not installed.\n" + " → pip install -r requirements-db-postgres.txt" + ) + sys.exit(1) else: try: from localdb import Database except ImportError: - LOGS.info("Using local file as database.") - os.system(f"{sys.executable} -m pip install -q localdb.json") - from localdb import Database + if _try_auto_pip("localdb.json", "local database"): + from localdb import Database + else: + LOGS.critical( + "No remote DB set and 'localdb' is not installed.\n" + " → Set REDIS_URI / MONGO_URI / DATABASE_URL, or:\n" + " → pip install localdb.json" + ) + sys.exit(1) # --------------------------------------------------------------------------------------------- # diff --git a/pyUltroid/startup/config_validate.py b/pyUltroid/startup/config_validate.py new file mode 100644 index 000000000..e9a5d8a6f --- /dev/null +++ b/pyUltroid/startup/config_validate.py @@ -0,0 +1,234 @@ +# Ultroid - UserBot +# Copyright (C) 2021-2026 TeamUltroid +# +# This file is a part of < https://github.com/TeamUltroid/Ultroid/ > +# PLease read the GNU Affero General Public License in +# . + +"""Boot-time config checks with actionable error messages. + +Hosted-safe: only hard-fails on truly required auth vars by default. +Set ULTROID_STRICT_CONFIG=1 for stricter checks (recommended for local). +""" + +from __future__ import annotations + +import os +import re +import sys + +# Telegram Android defaults — rejected so users set their own app credentials. +_DEFAULT_API_ID = {6, "6"} +_DEFAULT_API_HASH = {"eb06d4abfb49dc3eeb1aeb98ae0f581e"} + +_SESSION_HINT = ( + "Generate one with: bash sessiongen\n" + " or: python -m pyUltroid setup" +) +_API_HINT = ( + "Get your own from https://my.telegram.org (API development tools).\n" + "Do not use shared/public API credentials." +) + + +def _truthy(val) -> bool: + if val is None: + return False + return str(val).strip().lower() in {"1", "true", "yes", "on", "y"} + + +def strict_config() -> bool: + return _truthy(os.getenv("ULTROID_STRICT_CONFIG")) + + +def auto_pip_enabled() -> bool: + """Runtime pip install fallback. Default on for back-compat; disable on locked hosts.""" + env = os.getenv("ULTROID_AUTO_PIP") + if env is None: + return True + return _truthy(env) + + +def _missing(name: str, hint: str) -> str: + return f"Missing required config: {name}\n → {hint}" + + +def _invalid(name: str, detail: str, hint: str | None = None) -> str: + msg = f"Invalid config: {name} — {detail}" + if hint: + msg += f"\n → {hint}" + return msg + + +def validate_redis_uri(uri: str | None) -> list[str]: + errors = [] + if not uri: + return errors + raw = str(uri).strip() + if raw.lower().startswith(("http://", "https://", "redis://", "rediss://")): + errors.append( + _invalid( + "REDIS_URI", + "must not include a URL scheme", + "Use host:port only, e.g. redis-123.example.com:12345 " + "(password goes in REDIS_PASSWORD).", + ) + ) + return errors + if ":" not in raw: + errors.append( + _invalid( + "REDIS_URI", + "expected host:port", + "Example: redis-123.example.com:12345", + ) + ) + return errors + host, _, port = raw.rpartition(":") + if not host.strip(): + errors.append(_invalid("REDIS_URI", "host is empty", "Example: 1.2.3.4:6379")) + if not port.isdigit() or not (1 <= int(port) <= 65535): + errors.append( + _invalid("REDIS_URI", f"bad port '{port}'", "Port must be 1–65535") + ) + return errors + + +def validate_mongo_uri(uri: str | None) -> list[str]: + if not uri: + return [] + if not str(uri).strip().startswith(("mongodb://", "mongodb+srv://")): + return [ + _invalid( + "MONGO_URI", + "should start with mongodb:// or mongodb+srv://", + "Copy the full URI from your MongoDB provider.", + ) + ] + return [] + + +def validate_database_url(url: str | None) -> list[str]: + if not url: + return [] + if not re.match(r"^postgres(ql)?://", str(url).strip(), re.I): + return [ + _invalid( + "DATABASE_URL", + "expected a postgres:// or postgresql:// URL", + "Use the URI from your Postgres provider (e.g. Railway/Heroku/ElephantSQL).", + ) + ] + return [] + + +def collect_config_issues(Var) -> tuple[list[str], list[str]]: + """Return (errors, warnings). Errors should abort boot.""" + errors: list[str] = [] + warnings: list[str] = [] + + api_id = getattr(Var, "API_ID", None) + api_hash = getattr(Var, "API_HASH", None) + session = getattr(Var, "SESSION", None) + + if api_id in (None, "", 0) or api_hash in (None, ""): + errors.append(_missing("API_ID / API_HASH", _API_HINT)) + else: + try: + api_id_int = int(api_id) + except (TypeError, ValueError): + errors.append(_invalid("API_ID", f"not an integer ({api_id!r})", _API_HINT)) + api_id_int = None + if api_id_int in _DEFAULT_API_ID or str(api_hash).lower() in _DEFAULT_API_HASH: + errors.append( + _invalid( + "API_ID / API_HASH", + "Telegram Android defaults are not allowed", + _API_HINT, + ) + ) + + bot_mode = _truthy(os.getenv("BOTMODE")) + if not session and not bot_mode: + # BOTMODE can run on BOT_TOKEN alone; normal userbot needs SESSION. + if not getattr(Var, "BOT_TOKEN", None): + errors.append(_missing("SESSION", _SESSION_HINT)) + else: + warnings.append( + "SESSION is unset. Without BOTMODE this will fail after DB connect. " + + _SESSION_HINT + ) + + errors.extend(validate_redis_uri(getattr(Var, "REDIS_URI", None))) + if getattr(Var, "REDISHOST", None) and not getattr(Var, "REDIS_URI", None): + # Railway-style split vars — port required when host set + port = getattr(Var, "REDISPORT", None) + if not port: + warnings.append( + "REDISHOST is set but REDISPORT is empty. " + "Set REDISPORT or use REDIS_URI=host:port." + ) + + errors.extend(validate_mongo_uri(getattr(Var, "MONGO_URI", None))) + errors.extend(validate_database_url(getattr(Var, "DATABASE_URL", None))) + + has_db = any( + [ + getattr(Var, "REDIS_URI", None), + getattr(Var, "REDISHOST", None), + getattr(Var, "MONGO_URI", None), + getattr(Var, "DATABASE_URL", None), + ] + ) + if not has_db: + msg = ( + "No remote database configured (REDIS_URI / MONGO_URI / DATABASE_URL). " + "Falling back to LocalDB (file). Fine for trials; use Redis/Mongo/Postgres in production." + ) + if strict_config(): + errors.append(msg + "\n → Strict mode (ULTROID_STRICT_CONFIG=1) requires a remote DB.") + else: + warnings.append(msg) + + return errors, warnings + + +def validate_config_or_exit(Var, logger=None) -> None: + """Log warnings; exit process if hard errors are present.""" + errors, warnings = collect_config_issues(Var) + log = logger + if log is None: + import logging + + log = logging.getLogger("pyUltLogs") + + for w in warnings: + log.warning("[config] %s", w) + + if not errors: + return + + log.error( + "Configuration check failed (%s issue%s). Fix the following and restart:", + len(errors), + "s" if len(errors) != 1 else "", + ) + for i, err in enumerate(errors, 1): + lines = err.splitlines() or [err] + for idx, line in enumerate(lines): + prefix = f"{i}. " if idx == 0 else " " + log.error(" %s%s", prefix, line) + + log.error( + "Docs: https://ultroid.tech | Sample env: .env.sample | Guided setup: python -m pyUltroid setup" + ) + sys.exit(1) + + +def pip_install_hint(packages: str) -> str: + return ( + f"Missing Python package(s): {packages}\n" + f" → Install: {sys.executable} -m pip install {packages}\n" + f" → Or use a requirements profile (see requirements-db-*.txt / requirements-full.txt)\n" + f" → Temporary auto-install: set ULTROID_AUTO_PIP=1 (not recommended on locked hosts)" + ) diff --git a/pyUltroid/startup/connections.py b/pyUltroid/startup/connections.py index 8a1f34063..f4d160c3e 100644 --- a/pyUltroid/startup/connections.py +++ b/pyUltroid/startup/connections.py @@ -33,12 +33,20 @@ def validate_session(session, logger=LOGS, _exit=True): from strings import get_string + _hint = ( + "Generate a new session: bash sessiongen\n" + " or: python -m pyUltroid setup\n" + "Then set SESSION in .env / host config vars and restart." + ) + if session: # Telethon Session if session.startswith(CURRENT_VERSION): if len(session.strip()) != 353: - logger.exception(get_string("py_c1")) - sys.exit() + logger.error("%s\n → %s", get_string("py_c1"), _hint) + if _exit: + sys.exit(1) + return return StringSession(session) # Pyrogram Session @@ -65,12 +73,22 @@ def validate_session(session, logger=LOGS, _exit=True): ).decode("ascii") ) else: - logger.exception(get_string("py_c1")) + logger.error( + "SESSION is not a valid Telethon or Pyrogram string session.\n" + " → %s\n → %s", + get_string("py_c1"), + _hint, + ) if _exit: - sys.exit() - logger.exception(get_string("py_c2")) + sys.exit(1) + return + logger.error( + "SESSION is missing.\n → %s\n → %s", + get_string("py_c2"), + _hint, + ) if _exit: - sys.exit() + sys.exit(1) def vc_connection(udB, ultroid_bot): diff --git a/pyUltroid/startup/funcs.py b/pyUltroid/startup/funcs.py index b893aebd2..44bafce20 100644 --- a/pyUltroid/startup/funcs.py +++ b/pyUltroid/startup/funcs.py @@ -469,14 +469,21 @@ async def plug(plugin_channels): async def ready(): from .. import asst, udB, ultroid_bot + from ..configs import Var chat_id = udB.get_key("LOG_CHANNEL") spam_sent = None if not udB.get_key("INIT_DEPLOY"): # Detailed Message at Initial Deploy MSG = """🎇 **Thanks for Deploying Ultroid Userbot!** -• Here, are the Some Basic stuff from, where you can Know, about its Usage.""" +• Tap below for a short onboarding (language, handler, optional toggles). +• Or run `{i}help` anytime.""".replace( + "{i}", udB.get_key("HNDLR") or "." + ) PHOTO = "https://graph.org/file/54a917cc9dbb94733ea5f.jpg" - BTTS = Button.inline("• Click to Start •", "initft_2") + BTTS = [ + [Button.inline("• Click to Start •", "initft_2")], + [Button.inline("⚙️ Quick setup", "onboard_menu")], + ] udB.set_key("INIT_DEPLOY", "Done") else: MSG = f"**Ultroid has been deployed!**\n➖➖➖➖➖➖➖➖➖➖\n**UserMode**: {inline_mention(ultroid_bot.me)}\n**Assistant**: @{asst.me.username}\n➖➖➖➖➖➖➖➖➖➖\n**Support**: @TeamUltroid\n➖➖➖➖➖➖➖➖➖➖" @@ -507,6 +514,10 @@ async def ready(): if spam_sent and not spam_sent.media: udB.set_key("LAST_UPDATE_LOG_SPAM", spam_sent.id) + skip_join = Var.SKIP_AUTOJOIN or udB.get_key("SKIP_AUTOJOIN") + if skip_join: + LOGS.info("SKIP_AUTOJOIN set — not joining @TheUltroid.") + return try: await ultroid_bot(JoinChannelRequest("TheUltroid")) except Exception as er: diff --git a/pyUltroid/startup/setup_cli.py b/pyUltroid/startup/setup_cli.py new file mode 100644 index 000000000..2cb132372 --- /dev/null +++ b/pyUltroid/startup/setup_cli.py @@ -0,0 +1,325 @@ +# Ultroid - UserBot +# Copyright (C) 2021-2026 TeamUltroid +# +# This file is a part of < https://github.com/TeamUltroid/Ultroid/ > +# PLease read the GNU Affero General Public License in +# . + +"""Guided local setup. Hosted platforms should set config vars in the dashboard instead. + +Usage: + python -m pyUltroid setup + python -m pyUltroid doctor +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path.cwd() +ENV_PATH = ROOT / ".env" +ENV_SAMPLE = ROOT / ".env.sample" + +PROFILES = { + "minimal": ["requirements.txt"], + "db-redis": ["requirements.txt", "requirements-db-redis.txt"], + "db-mongo": ["requirements.txt", "requirements-db-mongo.txt"], + "db-postgres": ["requirements.txt", "requirements-db-postgres.txt"], + "full": ["requirements.txt", "requirements-full.txt"], + "termux": ["requirements.txt"], +} + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _prompt(msg: str, default: str | None = None) -> str: + suffix = f" [{default}]" if default not in (None, "") else "" + try: + val = input(f"{msg}{suffix}: ").strip() + except EOFError: + return default or "" + return val if val else (default or "") + + +def _yes(msg: str, default: bool = True) -> bool: + hint = "Y/n" if default else "y/N" + ans = _prompt(f"{msg} ({hint})", "y" if default else "n").lower() + if not ans: + return default + return ans in {"y", "yes", "1", "true"} + + +def _write_env(values: dict[str, str]) -> None: + lines = [ + "# Generated by: python -m pyUltroid setup", + "# Don't use quotes ( \" and ' )", + "", + ] + order = [ + "API_ID", + "API_HASH", + "SESSION", + "BOT_TOKEN", + "LOG_CHANNEL", + "REDIS_URI", + "REDIS_PASSWORD", + "MONGO_URI", + "DATABASE_URL", + "ADDONS", + "VCBOT", + "ULTROID_AUTO_PIP", + "ULTROID_STRICT_CONFIG", + "SKIP_AUTOPILOT", + "SKIP_AUTOJOIN", + "SKIP_AUTOBOT", + "SKIP_ASSISTANT_CUSTOMIZE", + ] + seen = set() + for key in order: + if key in values and values[key] not in (None, ""): + lines.append(f"{key}={values[key]}") + seen.add(key) + for key, val in values.items(): + if key not in seen and val not in (None, ""): + lines.append(f"{key}={val}") + ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"\nWrote {ENV_PATH}") + + +def _pip_install(req_files: list[str]) -> int: + for req in req_files: + path = ROOT / req + if not path.exists(): + print(f" skip missing {req}") + continue + print(f" pip install -r {req}") + rc = subprocess.call( + [sys.executable, "-m", "pip", "install", "-q", "--no-cache-dir", "-r", str(path)] + ) + if rc != 0: + return rc + return 0 + + +def _probe_import(name: str) -> bool: + try: + __import__(name) + return True + except Exception: + return False + + +def cmd_doctor() -> int: + print("Ultroid doctor\n" + "-" * 40) + print(f"Python : {platform.python_version()} ({sys.executable})") + print(f"Platform : {platform.platform()}") + print(f"Cwd : {ROOT}") + print(f".env : {'yes' if ENV_PATH.exists() else 'no'}") + print(f"plugins/ : {'yes' if (ROOT / 'plugins').is_dir() else 'MISSING'}") + print(f"ffmpeg : {shutil.which('ffmpeg') or 'not found'}") + print(f"mediainfo : {shutil.which('mediainfo') or 'not found'}") + print(f"git : {shutil.which('git') or 'not found'}") + + mods = { + "telethon": "telethon", + "decouple": "decouple", + "dotenv": "dotenv", + "redis": "redis", + "pymongo": "pymongo", + "psycopg2": "psycopg2", + "localdb": "localdb", + "pytz": "pytz", + "apscheduler": "apscheduler", + "heroku3": "heroku3", + "yt_dlp": "yt_dlp", + } + print("\nPython packages:") + for label, mod in mods.items(): + ok = _probe_import(mod) + print(f" {'OK' if ok else '--'} {label}") + + # Light config check without full boot + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + try: + import importlib.util + + # Load config_validate without importing pyUltroid package side effects + cv_path = ROOT / "pyUltroid" / "startup" / "config_validate.py" + spec = importlib.util.spec_from_file_location("ultroid_config_validate", cv_path) + cv = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cv) + + try: + from decouple import config as _cfg + except ImportError: + print("\nConfig:") + print(" WARN: python-decouple not installed — install requirements.txt") + print(" (package probe above already lists missing deps)") + return 0 + + class _Var: + API_ID = _cfg("API_ID", default=None) + API_HASH = _cfg("API_HASH", default=None) + SESSION = _cfg("SESSION", default=None) + REDIS_URI = _cfg("REDIS_URI", default=None) or _cfg("REDIS_URL", default=None) + REDISHOST = _cfg("REDISHOST", default=None) + REDISPORT = _cfg("REDISPORT", default=None) + MONGO_URI = _cfg("MONGO_URI", default=None) + DATABASE_URL = _cfg("DATABASE_URL", default=None) + BOT_TOKEN = _cfg("BOT_TOKEN", default=None) + + try: + if _Var.API_ID is not None: + _Var.API_ID = int(_Var.API_ID) + except (TypeError, ValueError): + pass + + errors, warnings = cv.collect_config_issues(_Var) + print("\nConfig:") + if not errors and not warnings: + print(" OK — no issues detected") + for w in warnings: + print(f" WARN: {w.splitlines()[0]}") + for e in errors: + print(f" ERR: {e.splitlines()[0]}") + return 1 if errors else 0 + except Exception as er: + print(f"\nConfig check skipped: {er}") + return 0 + + +def cmd_setup() -> int: + print( + """ +╔══════════════════════════════════════╗ +║ Ultroid setup wizard ║ +╚══════════════════════════════════════╝ +Hosted (Heroku/Railway/etc.): set config vars in the dashboard — do not run this there. +Local/VPS: follow the prompts. Non-interactive: use .env.sample → .env +""" + ) + if not _is_interactive(): + print( + "Non-interactive terminal detected.\n" + f"Copy {ENV_SAMPLE.name} to .env, fill values, then:\n" + " pip install -r requirements.txt\n" + " pip install -r requirements-db-redis.txt # or mongo/postgres\n" + " bash sessiongen\n" + " python -m pyUltroid\n" + ) + if ENV_SAMPLE.exists() and not ENV_PATH.exists(): + shutil.copy(ENV_SAMPLE, ENV_PATH) + print(f"Created {ENV_PATH} from sample — edit it before starting.") + return 0 + + if not (ROOT / "plugins").is_dir() or not (ROOT / "pyUltroid").is_dir(): + print("Run this from the Ultroid repo root (plugins/ and pyUltroid/ required).") + return 1 + + values: dict[str, str] = {} + print("Get API credentials from https://my.telegram.org\n") + values["API_ID"] = _prompt("API_ID") + values["API_HASH"] = _prompt("API_HASH") + values["SESSION"] = _prompt( + "SESSION string (leave empty and run bash sessiongen later)", "" + ) + values["BOT_TOKEN"] = _prompt( + "BOT_TOKEN (optional — autopilot can create one)", "" + ) + values["LOG_CHANNEL"] = _prompt("LOG_CHANNEL id (optional)", "") + + print("\nDatabase backend:") + print(" 1) Redis (recommended)") + print(" 2) MongoDB") + print(" 3) PostgreSQL") + print(" 4) Local file DB (dev only)") + choice = _prompt("Choice", "1") + profile = "minimal" + if choice == "1": + values["REDIS_URI"] = _prompt("REDIS_URI (host:port, no redis://)") + values["REDIS_PASSWORD"] = _prompt("REDIS_PASSWORD", "") + profile = "db-redis" + elif choice == "2": + values["MONGO_URI"] = _prompt("MONGO_URI") + profile = "db-mongo" + elif choice == "3": + values["DATABASE_URL"] = _prompt("DATABASE_URL (postgres://...)") + profile = "db-postgres" + else: + profile = "minimal" + print("Using LocalDB — fine for trials, not production.") + + if _yes("Install full optional plugin dependencies?", default=False): + profile = "full" + + values["ADDONS"] = "True" if _yes("Enable UltroidAddons?", default=False) else "False" + values["VCBOT"] = "True" if _yes("Enable VCBOT?", default=False) else "False" + + print("\nAutopilot side-effects (safe defaults for first run):") + values["SKIP_AUTOJOIN"] = ( + "True" if _yes("Skip auto-join @TheUltroid?", default=False) else "False" + ) + values["SKIP_AUTOBOT"] = ( + "True" + if _yes("Skip auto BotFather bot creation (set BOT_TOKEN yourself)?", default=False) + else "False" + ) + values["SKIP_ASSISTANT_CUSTOMIZE"] = ( + "True" if _yes("Skip assistant @BotFather customization?", default=True) else "False" + ) + values["ULTROID_AUTO_PIP"] = ( + "1" if _yes("Allow runtime pip install fallback?", default=True) else "0" + ) + values["ULTROID_STRICT_CONFIG"] = ( + "1" if _yes("Strict config (require remote DB)?", default=False) else "0" + ) + + if ENV_PATH.exists() and not _yes(f"Overwrite existing {ENV_PATH}?", default=False): + print("Keeping existing .env") + else: + _write_env(values) + + if _yes(f"Install Python deps (profile: {profile})?", default=True): + reqs = PROFILES.get(profile, PROFILES["minimal"]) + rc = _pip_install(reqs) + if rc != 0: + print("pip failed — install requirements manually.") + return rc + + if not values.get("SESSION"): + print( + "\nNo SESSION yet. Generate one:\n" + " bash sessiongen\n" + "Then put the string in .env as SESSION=..." + ) + + print( + "\nDone. Start Ultroid with:\n" + " bash startup\n" + " # or: python -m pyUltroid\n" + "\nHealth check anytime:\n" + " python -m pyUltroid doctor\n" + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + if not argv or argv[0] in {"-h", "--help", "help"}: + print(__doc__) + return 0 + cmd = argv[0] + if cmd == "setup": + return cmd_setup() + if cmd == "doctor": + return cmd_doctor() + print(f"Unknown command: {cmd}\n{__doc__}") + return 2 diff --git a/requirements-db-mongo.txt b/requirements-db-mongo.txt new file mode 100644 index 000000000..18eeb203b --- /dev/null +++ b/requirements-db-mongo.txt @@ -0,0 +1,2 @@ +# MongoDB database driver (install with requirements.txt) +pymongo[srv]>=4.6.0 diff --git a/requirements-db-postgres.txt b/requirements-db-postgres.txt new file mode 100644 index 000000000..c46a0e25d --- /dev/null +++ b/requirements-db-postgres.txt @@ -0,0 +1,2 @@ +# PostgreSQL database driver (install with requirements.txt) +psycopg2-binary>=2.9.9 diff --git a/requirements-db-redis.txt b/requirements-db-redis.txt new file mode 100644 index 000000000..08e238ffe --- /dev/null +++ b/requirements-db-redis.txt @@ -0,0 +1,3 @@ +# Redis database driver (install with requirements.txt) +redis>=5.0.0 +hiredis>=2.3.0 diff --git a/requirements-full.txt b/requirements-full.txt new file mode 100644 index 000000000..45ed44669 --- /dev/null +++ b/requirements-full.txt @@ -0,0 +1,8 @@ +# Full local/plugin dependency set. +# pip install -r requirements.txt -r requirements-full.txt +# Plus the DB profile you use (requirements-db-redis.txt / mongo / postgres). + +-r requirements.txt +-r resources/startup/optional-requirements.txt +-r requirements-db-redis.txt +localdb.json diff --git a/requirements.txt b/requirements.txt index 7a06ed940..3c10e0dae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -# Important Requirements here. +# Core Ultroid requirements (always install these). +# DB drivers: also install one of +# requirements-db-redis.txt | requirements-db-mongo.txt | requirements-db-postgres.txt +# Full plugins: requirements-full.txt +# Setup wizard: python -m pyUltroid setup telethon>=1.44.0 gitpython>=3.1.52 https://github.com/New-dev0/Telethon-Patch/archive/main.zip @@ -10,3 +14,4 @@ requests>=2.34.2 aiohttp>=3.14.1 catbox-uploader>=2.9 cloudscraper>=1.2.71 + diff --git a/tests/test_p0_qol.py b/tests/test_p0_qol.py new file mode 100644 index 000000000..80404f626 --- /dev/null +++ b/tests/test_p0_qol.py @@ -0,0 +1,209 @@ +# Ultroid - UserBot +# P0 QoL unit tests (no Telegram / no network). + +from __future__ import annotations + +import importlib +import os +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _load_validate(): + """Import config_validate without booting the full package.""" + # Ensure pyUltroid is a package path without running __init__ side effects + pkg = types.ModuleType("pyUltroid") + pkg.__path__ = [str(ROOT / "pyUltroid")] + pkg.__file__ = str(ROOT / "pyUltroid" / "__init__.py") + sys.modules.setdefault("pyUltroid", pkg) + + startup = types.ModuleType("pyUltroid.startup") + startup.__path__ = [str(ROOT / "pyUltroid" / "startup")] + startup.__file__ = str(ROOT / "pyUltroid" / "startup" / "__init__.py") + sys.modules.setdefault("pyUltroid.startup", startup) + + name = "pyUltroid.startup.config_validate" + if name in sys.modules: + return importlib.reload(sys.modules[name]) + return importlib.import_module(name) + + +class _V: + def __init__(self, **kw): + defaults = dict( + API_ID=12345, + API_HASH="0123456789abcdef0123456789abcdef", + SESSION="1" * 353, + REDIS_URI=None, + REDISHOST=None, + REDISPORT=None, + MONGO_URI=None, + DATABASE_URL=None, + BOT_TOKEN=None, + ) + defaults.update(kw) + for k, v in defaults.items(): + setattr(self, k, v) + + +class ConfigValidateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.cv = _load_validate() + + def test_rejects_default_api_credentials(self): + v = _V(API_ID=6, API_HASH="eb06d4abfb49dc3eeb1aeb98ae0f581e") + errors, _ = self.cv.collect_config_issues(v) + self.assertTrue(any("defaults" in e.lower() or "API_ID" in e for e in errors)) + + def test_accepts_custom_api(self): + v = _V(REDIS_URI="127.0.0.1:6379") + errors, warnings = self.cv.collect_config_issues(v) + self.assertEqual(errors, []) + + def test_redis_uri_rejects_scheme(self): + errs = self.cv.validate_redis_uri("redis://host:6379") + self.assertTrue(errs) + errs = self.cv.validate_redis_uri("https://host:6379") + self.assertTrue(errs) + + def test_redis_uri_requires_port(self): + errs = self.cv.validate_redis_uri("only-host") + self.assertTrue(errs) + + def test_redis_uri_ok(self): + self.assertEqual(self.cv.validate_redis_uri("redis-123.c1.example.com:12345"), []) + + def test_mongo_uri(self): + self.assertTrue(self.cv.validate_mongo_uri("http://bad")) + self.assertEqual( + self.cv.validate_mongo_uri("mongodb+srv://u:p@cluster/db"), [] + ) + + def test_database_url(self): + self.assertTrue(self.cv.validate_database_url("mysql://x")) + self.assertEqual( + self.cv.validate_database_url("postgres://u:p@h:5432/db"), [] + ) + + def test_missing_session_errors(self): + v = _V(SESSION=None, BOT_TOKEN=None, REDIS_URI="h:1") + errors, _ = self.cv.collect_config_issues(v) + self.assertTrue(any("SESSION" in e for e in errors)) + + def test_localdb_warning_without_strict(self): + with mock.patch.dict(os.environ, {"ULTROID_STRICT_CONFIG": "0"}, clear=False): + # reload truthy path + v = _V(SESSION="x" * 353) + errors, warnings = self.cv.collect_config_issues(v) + self.assertFalse(any("Strict mode" in e for e in errors)) + self.assertTrue(any("LocalDB" in w or "remote database" in w for w in warnings)) + + def test_strict_requires_remote_db(self): + with mock.patch.dict(os.environ, {"ULTROID_STRICT_CONFIG": "1"}, clear=False): + # force re-read of env inside strict_config + v = _V(SESSION="x" * 353) + errors, _ = self.cv.collect_config_issues(v) + self.assertTrue(any("Strict mode" in e or "remote" in e.lower() for e in errors)) + + def test_auto_pip_default_on(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("ULTROID_AUTO_PIP", None) + self.assertTrue(self.cv.auto_pip_enabled()) + + def test_auto_pip_off(self): + with mock.patch.dict(os.environ, {"ULTROID_AUTO_PIP": "0"}): + self.assertFalse(self.cv.auto_pip_enabled()) + + def test_validate_or_exit_ok(self): + v = _V(REDIS_URI="127.0.0.1:6379") + log = mock.Mock() + self.cv.validate_config_or_exit(v, log) + + def test_validate_or_exit_fails(self): + v = _V(API_ID=6, API_HASH="eb06d4abfb49dc3eeb1aeb98ae0f581e") + log = mock.Mock() + with self.assertRaises(SystemExit) as ctx: + self.cv.validate_config_or_exit(v, log) + self.assertEqual(ctx.exception.code, 1) + + +class SetupCliTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Load setup_cli similarly isolated + pkg = types.ModuleType("pyUltroid") + pkg.__path__ = [str(ROOT / "pyUltroid")] + sys.modules.setdefault("pyUltroid", pkg) + startup = types.ModuleType("pyUltroid.startup") + startup.__path__ = [str(ROOT / "pyUltroid" / "startup")] + sys.modules.setdefault("pyUltroid.startup", startup) + name = "pyUltroid.startup.setup_cli" + if name in sys.modules: + cls.cli = importlib.reload(sys.modules[name]) + else: + cls.cli = importlib.import_module(name) + + def test_help(self): + rc = self.cli.main(["--help"]) + self.assertEqual(rc, 0) + + def test_unknown(self): + rc = self.cli.main(["nope"]) + self.assertEqual(rc, 2) + + def test_setup_noninteractive(self): + tmp = ROOT / ".env_test_tmp_setup" + if tmp.exists(): + tmp.unlink() + with mock.patch.object(self.cli, "_is_interactive", return_value=False): + with mock.patch.object(self.cli, "ENV_PATH", tmp): + with mock.patch.object(self.cli, "ENV_SAMPLE", ROOT / ".env.sample"): + # avoid copying over a real path; sample exists + rc = self.cli.cmd_setup() + self.assertEqual(rc, 0) + if tmp.exists(): + tmp.unlink() + + def test_profiles_exist(self): + for reqs in self.cli.PROFILES.values(): + for r in reqs: + path = ROOT / r + # termux/minimal only need requirements.txt + if r == "requirements.txt": + self.assertTrue(path.exists(), r) + + +class EnvSampleTests(unittest.TestCase): + def test_sample_has_required_keys(self): + text = (ROOT / ".env.sample").read_text(encoding="utf-8") + for key in ( + "API_ID", + "API_HASH", + "SESSION", + "REDIS_URI", + "ULTROID_AUTO_PIP", + "SKIP_AUTOPILOT", + "SKIP_AUTOJOIN", + ): + self.assertIn(key, text) + + def test_db_requirement_files(self): + for name in ( + "requirements-db-redis.txt", + "requirements-db-mongo.txt", + "requirements-db-postgres.txt", + "requirements-full.txt", + ): + self.assertTrue((ROOT / name).exists(), name) + + +if __name__ == "__main__": + unittest.main()