cm_bot_v2/app/cm_telegram.py
yiekheng 45303d00aa Refactor: externalize all hardcoded config to env vars, add multi-deployment support
- Remove all hardcoded credentials and config from Python source code:
  - db.py: DB host/user/password/name/port → env vars with connection retry support
  - cm_bot_hal.py: prefix, agent_id, agent_password, security_pin → env vars
  - cm_bot.py: base_url → env var, fix register_user return values
  - cm_web_view.py: hardcoded '13c' prefix → configurable CM_PREFIX_PATTERN
  - cm_telegram.py: hardcoded 'Sky533535' pin → env var CM_SECURITY_PIN

- Parameterize docker-compose.yml for multi-deployment on same host:
  - Container names use ${CM_DEPLOY_NAME} prefix (e.g. rex-cm-*, siong-cm-*)
  - Network name uses ${CM_DEPLOY_NAME}-network
  - Web view port configurable via ${CM_WEB_HOST_PORT}
  - All service config passed as env vars (not baked into image)

- Add per-deployment env configs:
  - envs/rex/.env (port 8001, prefix 13c, DB rex_cm)
  - envs/siong/.env (port 8005, prefix 13sa, DB siong_cm)
  - .env.example as template for new deployments
  - Remove .env from .gitignore (local server, safe to commit)

- Improve telegram bot reliability:
  - Add retry logic for polling with exponential backoff
  - Add error handlers for Conflict, RetryAfter, NetworkError, TimedOut
  - Add /9 command to show chat ID
  - Add telegram_notifier.py for alert notifications
  - Fix error handling in /2 and /3 command handlers

- Fix db.py cursor cleanup (close cursor before connection in finally blocks)
- Fix docker-compose.override.yml environment syntax (list → mapping)
- Update README with multi-deployment instructions
- Add AGENTS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 22:25:40 +08:00

264 lines
10 KiB
Python

import threading, logging, time, asyncio, os
from typing import Optional
from telegram import ForceReply, Update
from telegram.error import Conflict, InvalidToken, NetworkError, RetryAfter, TimedOut
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from telegram.request import HTTPXRequest
from .cm_bot_hal import CM_BOT_HAL
from .telegram_notifier import TelegramNotifier, get_telegram_bot_token
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
creating_acc_now = False
def _get_required_env(name: str) -> str:
value = os.getenv(name)
if value is None or value == "":
raise RuntimeError(f"Missing required environment variable: {name}")
return value
def _get_env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except ValueError:
return default
def _get_env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default
def _retry_after_seconds(retry_after) -> int:
if hasattr(retry_after, "total_seconds"):
return max(1, int(retry_after.total_seconds()))
return max(1, int(retry_after))
async def menu_cmd_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
menu = [
'MENU',
'/1 - Get Acc',
'/2 <link> - Set Security Pin',
'/3 <agent username> <player username>',
'/9 - Show Chat ID'
]
await update.message.reply_text('\n'.join(menu))
async def get_acc_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
global creating_acc_now
while creating_acc_now == True:
await update.message.reply_text('CM account creation is running, queuing ...')
await asyncio.sleep(1)
creating_acc_now = True
await update.message.reply_text('Start Getting CM Account ...')
try:
bot = CM_BOT_HAL()
user = bot.get_user_api()
msg = [
f'Username: {user["username"]}',
f'Password: {user["password"]}',
f'Link: {user["link"]}'
]
await update.message.reply_text('\n'.join(msg))
except Exception as e:
await update.message.reply_text(f'Error: {e}')
finally:
creating_acc_now = False
async def set_security_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if len(context.args) == 0 or len(context.args) > 1:
await update.message.reply_text('CMD is wrong, please check and retry ...')
return
try:
bot = CM_BOT_HAL()
if bot.is_whatsapp_url(context.args[0]) == False:
await update.message.reply_text('Link Format Wrong, please check and retry ...')
return
await update.message.reply_text('Start Setting Security Pin ...')
result = bot.set_security_pin_api(context.args[0])
del bot
await update.message.reply_text(f"Done setting Security Pin for {result['f_username']} - {result['t_username']} !")
except Exception as e:
await update.message.reply_text(f'Error: {e}')
async def insert_to_user_table_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if len(context.args) == 0 or len(context.args) != 2:
await update.message.reply_text('CMD is wrong, please check and retry ...')
return
try:
bot = CM_BOT_HAL()
f_username, t_username = context.args
security_pin = _get_required_env("CM_SECURITY_PIN")
f_password = bot.get_user_pass_from_acc(f_username)
if not f_password:
raise Exception(f'Cannot find password for {f_username}')
success = bot.insert_user_to_table_user(
{
'f_username': f_username,
'f_password': f_password,
't_username': t_username,
't_password': security_pin
}
)
if success is False:
raise Exception('Failed to insert user into table')
await update.message.reply_text(f'Done insert {f_username} into user table.')
except Exception as e:
await update.message.reply_text(f'Error: {e}')
async def show_chat_id_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id if update.effective_chat else "Unknown"
await update.message.reply_text(f'Chat ID: {chat_id}')
def monitor_amount_of_available_acc(notifier: Optional[TelegramNotifier] = None):
global creating_acc_now
max_available = 20
notifier = notifier or TelegramNotifier()
while True:
bot = None
try:
bot = CM_BOT_HAL()
available_size = len(bot.get_all_available_acc())
if available_size <= max_available:
creating_acc_now = True
for i in range(available_size, max_available):
try:
bot.create_new_acc()
except Exception as exc:
err_text = str(exc)
logger.exception("Failed to auto create CM account: %s", err_text)
if notifier:
if '[Fail login]' in err_text:
notifier.notify_login_failure(err_text)
else:
notifier.notify_generic_error(err_text)
break
except Exception as exc:
logger.exception("Unexpected error while monitoring accounts: %s", exc)
if notifier:
notifier.notify_generic_error(str(exc))
finally:
creating_acc_now = False
if bot is not None:
del bot
time.sleep(10 * 60)
async def telegram_error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
err = context.error
if err is None:
return
if isinstance(err, Conflict):
logger.warning(
"Telegram polling conflict detected. Ensure only one bot instance runs with this token. %s",
err,
)
return
if isinstance(err, RetryAfter):
wait_seconds = _retry_after_seconds(err.retry_after)
logger.warning("Telegram flood control exceeded. Retry after %s seconds.", wait_seconds)
return
if isinstance(err, (NetworkError, TimedOut)):
logger.warning("Telegram network error: %s", err)
return
logger.exception("Unhandled Telegram update error", exc_info=err)
def build_application(bot_token: str) -> Application:
request_kwargs = dict(
connect_timeout=_get_env_float('TELEGRAM_CONNECT_TIMEOUT', 10.0),
read_timeout=_get_env_float('TELEGRAM_READ_TIMEOUT', 30.0),
write_timeout=_get_env_float('TELEGRAM_WRITE_TIMEOUT', 30.0),
pool_timeout=_get_env_float('TELEGRAM_POOL_TIMEOUT', 10.0),
)
request = HTTPXRequest(**request_kwargs)
get_updates_request = HTTPXRequest(**request_kwargs)
application = (
Application.builder()
.token(bot_token)
.request(request)
.get_updates_request(get_updates_request)
.build()
)
application.add_handler(CommandHandler("menu", menu_cmd_handler))
application.add_handler(CommandHandler("1", get_acc_handler))
application.add_handler(CommandHandler("2", set_security_handler))
application.add_handler(CommandHandler("3", insert_to_user_table_handler))
application.add_handler(CommandHandler("9", show_chat_id_handler))
application.add_error_handler(telegram_error_handler)
# application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
return application
def run_polling_forever(bot_token: str) -> None:
retry_delay = max(1, _get_env_int('TELEGRAM_POLLING_RETRY_DELAY', 5))
max_retry_delay = max(retry_delay, _get_env_int('TELEGRAM_POLLING_MAX_RETRY_DELAY', 60))
bootstrap_retries = _get_env_int('TELEGRAM_BOOTSTRAP_RETRIES', 0)
while True:
logger.info(
"Starting Telegram polling (bootstrap_retries=%s, long_poll_timeout=%s, retry_delay=%ss).",
bootstrap_retries,
_get_env_int('TELEGRAM_LONG_POLL_TIMEOUT', 30),
retry_delay,
)
application = build_application(bot_token)
try:
application.run_polling(
allowed_updates=Update.ALL_TYPES,
timeout=_get_env_int('TELEGRAM_LONG_POLL_TIMEOUT', 30),
bootstrap_retries=bootstrap_retries,
)
return
except InvalidToken as exc:
logger.exception(
"Invalid Telegram bot token. Check TELEGRAM_BOT_TOKEN and restart after fixing it: %s",
exc,
)
raise
except RetryAfter as exc:
wait_seconds = _retry_after_seconds(exc.retry_after)
logger.warning("Telegram flood control exceeded. Restarting polling in %s seconds.", wait_seconds)
time.sleep(wait_seconds)
retry_delay = min(wait_seconds, max_retry_delay)
except Conflict as exc:
logger.warning("Telegram polling conflict detected: %s", exc)
logger.warning("Another bot instance is likely running with the same token. Retrying in %s seconds.", retry_delay)
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, max_retry_delay)
except (NetworkError, TimedOut) as exc:
logger.warning("Telegram network error while polling: %s", exc)
logger.warning("Retrying polling in %s seconds.", retry_delay)
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, max_retry_delay)
except Exception as exc:
logger.exception("Unexpected polling crash: %s", exc)
logger.warning("Retrying polling in %s seconds.", retry_delay)
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, max_retry_delay)
def main() -> None:
"""Start the bot."""
bot_token = get_telegram_bot_token()
# Start the Telegram bot
print("Starting Telegram bot...")
notifier = TelegramNotifier()
threading.Thread(target=monitor_amount_of_available_acc, args=(notifier,), daemon=True).start()
run_polling_forever(bot_token)
if __name__ == "__main__":
main()