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>
This commit is contained in:
yiekheng 2026-03-22 22:25:40 +08:00
parent d73439698a
commit 45303d00aa
14 changed files with 712 additions and 142 deletions

2
.env
View File

@ -1,2 +0,0 @@
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest

31
.env.example Normal file
View File

@ -0,0 +1,31 @@
# === Deployment Identity ===
# Unique name prefix for containers and network (avoid conflicts on same host)
CM_DEPLOY_NAME=rex-cm
# Host port for web view (each deployment needs a unique port)
CM_WEB_HOST_PORT=8001
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALERT_CHAT_ID=
# TELEGRAM_ALERT_BOT_TOKEN=
# === Database ===
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=
CM_AGENT_ID=
CM_AGENT_PASSWORD=
CM_SECURITY_PIN=
CM_BOT_BASE_URL=

104
AGENTS.md Normal file
View File

@ -0,0 +1,104 @@
# Repository Guidelines
## Project Structure & Module Organization
- `app/` contains service modules:
- `cm_api.py` (Flask API, serves on `3000`)
- `cm_web_view.py` (Flask UI, container `8000`, host `8001`)
- `cm_telegram.py` (Telegram bot + account monitor thread)
- `cm_transfer_credit.py` (scheduled transfer worker)
- `db.py` (MySQL connection/retry logic)
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`).
- `docker-compose.yml` uses registry images; `docker-compose.override.yml` swaps to local builds.
- `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx.
## Reproduce From Scratch (Clean Machine)
1. Install prerequisites:
- Docker Engine + Docker Compose v2
- MySQL 8+ reachable by containers
- Telegram bot token(s) for bot and optional alerting
2. Clone and enter repo:
```bash
git clone <repo-url> cm_bot_v2
cd cm_bot_v2
```
3. Create `.env` at repo root for compose interpolation:
```bash
CM_IMAGE_PREFIX=local
DOCKER_IMAGE_TAG=dev
TELEGRAM_BOT_TOKEN=<required>
TELEGRAM_ALERT_CHAT_ID=<optional>
TELEGRAM_ALERT_BOT_TOKEN=<optional>
CM_TRANSFER_MAX_THREADS=1
```
4. Prepare MySQL schema (minimum required):
```sql
CREATE DATABASE rex_cm CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE rex_cm;
CREATE TABLE acc (
username VARCHAR(64) PRIMARY KEY,
password VARCHAR(128) NOT NULL,
status VARCHAR(32) DEFAULT '',
link VARCHAR(512) DEFAULT ''
);
CREATE TABLE user (
f_username VARCHAR(64) PRIMARY KEY,
f_password VARCHAR(128) NOT NULL,
t_username VARCHAR(64) NOT NULL,
t_password VARCHAR(128) NOT NULL,
last_update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
5. Seed at least one `acc.username` matching prefix `13c...` (required by `CM_BOT_HAL.get_next_username()`), for example:
```sql
INSERT INTO acc (username, password, status, link) VALUES ('13c1000', 'seed', '', '');
```
6. Configure DB connection values:
- Default fallback is hardcoded in `app/db.py` (`DB_HOST=192.168.0.210`, etc.).
- For reliable reproduction, add `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` to service `environment:` in compose files (at minimum `api-server`, `telegram-bot`, `transfer-bot`).
7. Start services locally:
```bash
docker compose -f docker-compose.yml -f docker-compose.override.yml up --build
```
Or run `bash scripts/local_build.sh` (uses `sudo` by default).
## Build, Test, and Development Commands
- `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`: optional non-Docker local env.
- `docker compose -f docker-compose.yml -f docker-compose.override.yml up --build`: local dev stack.
- `docker compose up -d`: run prebuilt/published images.
- `bash scripts/publish.sh <tag>`: build + push all service images (`gitea.04080616.xyz/yiekheng`).
## Verification Checklist
- API responds: `curl http://localhost:3000/acc/`
- Web UI loads: open `http://localhost:8001`
- Service logs are clean:
```bash
docker compose logs -f api-server web-view telegram-bot transfer-bot
```
- Telegram bot validates with `/menu` and `/9` in chat after startup.
## Coding Style & Naming Conventions
- Python 3.9, 4-space indentation, snake_case for variables/functions, module names as `cm_<role>.py`.
- Preserve existing class names (`CM_API`, `CM_BOT`, `CM_BOT_HAL`).
- Keep environment variable names uppercase and document new ones in this file.
- No enforced formatter/linter in-repo; match the surrounding style in touched files.
## Testing Guidelines
- No automated test suite is currently committed.
- Required minimum before PR: run verification checklist above on local compose.
- For logic-heavy changes, add `pytest` tests under `tests/` and include execution command/results in PR.
## Commit & Pull Request Guidelines
- Use short, focused commit subjects in imperative tone (existing history: `Fix ...`, `Update ...`, `Refactor ...`).
- Keep each commit scoped to one behavior change.
- PR must include:
- problem statement and solution summary,
- services/files affected,
- required env/config changes,
- API/log evidence (and UI screenshot if `cm_web_view.py` changed).
## Security & Configuration Tips
- Never commit real secrets in `.env`.
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use.
- Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# CM Bot v2 Portainer Setup (Gitea Registry)
Brief, copy/paste-ready steps to run the published images from `gitea.04080616.xyz` using Portainer.
## What gets deployed
- `cm-api` (port 3000), `cm-web` (port 8000 → host `CM_WEB_HOST_PORT`), `cm-telegram`, `cm-transfer`
- Container names prefixed with `CM_DEPLOY_NAME` (e.g. `rex-cm-telegram-bot`)
- Docker network: `${CM_DEPLOY_NAME}-network` (bridge)
## Environment configs
Pre-configured `.env` files for each deployment are in the `envs/` folder:
```
envs/
├── rex/.env # Rex deployment (port 8001)
└── siong/.env # Siong deployment (port 8005)
```
For local development, copy the desired env to the project root:
```bash
cp envs/rex/.env .env
# or
cp envs/siong/.env .env
```
For Portainer, load the env vars from the appropriate file into the stack environment variables.
## Key variables
| Variable | Description |
|---|---|
| `CM_DEPLOY_NAME` | Unique prefix for containers/network (e.g. `rex-cm`, `siong-cm`) |
| `CM_WEB_HOST_PORT` | Host port for web view (must be unique per deployment) |
| `TELEGRAM_BOT_TOKEN` | Your Telegram bot token |
| `DB_HOST` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Database connection |
| `CM_PREFIX_PATTERN` | Username prefix pattern |
| `CM_AGENT_ID` / `CM_AGENT_PASSWORD` / `CM_SECURITY_PIN` | Agent credentials |
| `CM_BOT_BASE_URL` | Bot API base URL |
## One-time: add the registry in Portainer
1) Portainer → **Registries****Add registry****Custom**.
2) Name: `gitea-prod` (any)
3) Registry URL: `gitea.04080616.xyz`
4) Username: your Gitea username; Password: the PAT. Save.
## Deploy the stack (fast path)
1) Portainer → **Stacks****Add stack****Web editor**.
2) Paste the contents of `docker-compose.yml` from this repo (not the override).
3) Load all variables from the appropriate `envs/<name>/.env` into the stack environment variables.
4) Click **Deploy the stack**. Portainer will pull `cm-<service>:<tag>` from `gitea.04080616.xyz/yiekheng` and start all four containers.
## Updating to a new image tag
1) Edit the stack → change `DOCKER_IMAGE_TAG`**Update the stack**.
2) Portainer re-pulls and recreates the services with the new tag.
## Running multiple deployments on same host
Each deployment needs unique values for:
- `CM_DEPLOY_NAME` avoids container/network name conflicts
- `CM_WEB_HOST_PORT` avoids port conflicts
## Common issues
- **Pull denied**: PAT missing `read:package` or wrong username/PAT in the registry entry.
- **Port already allocated**: check `CM_WEB_HOST_PORT` is unique across deployments.
- **No port bindings applied**: ensure network driver stays `bridge` (not `host` or `macvlan`).

View File

@ -1,6 +1,7 @@
import datetime
import requests, re
from bs4 import BeautifulSoup
import os
# with open('security_response.html', 'wb') as f:
# f.write(response.content)
@ -8,9 +9,15 @@ from bs4 import BeautifulSoup
class CM_BOT:
def __init__(self):
self.session = requests.Session()
self.base_url = 'https://cm99.net'
self.base_url = self._get_required_env('CM_BOT_BASE_URL')
self.is_logged_in = False
self._setup_headers()
def _get_required_env(self, name: str) -> str:
value = os.getenv(name)
if value is None or value == "":
raise RuntimeError(f"Missing required environment variable: {name}")
return value
def _setup_headers(self):
"""Set up default headers for requests."""
@ -323,7 +330,9 @@ class CM_BOT:
self.is_logged_in = False
return False
def get_max_user_in_pattern(self, prefix_pattern: str = '13c'):
def get_max_user_in_pattern(self, prefix_pattern: str = None):
if prefix_pattern is None:
prefix_pattern = self._get_required_env("CM_PREFIX_PATTERN")
response = self.session.get(f'{self.base_url}/cm/json/generateUserTree?id=0')
regex = f'\\[{prefix_pattern}\\d+]'
matches = re.findall(regex, response.text)
@ -363,12 +372,14 @@ class CM_BOT:
if re.search('User created successfully', response.text):
print(f"User account: {user_id} password: {user_password} creation completed!")
return True
else:
print(f"User account: {user_id} creation FAIL!")
return False
except requests.exceptions.RequestException as e:
print(f"Error creating user account: {e}")
return None
return False
# def change_user_password(self, user_id: str, new_user_pass: str):
# try:
@ -462,46 +473,7 @@ class CM_BOT:
def main():
# user_id='testing0001'
# user_pass='Qwer1@34'
# # prefix = '13c'
user_manager = CM_BOT()
user_manager.login(
username = '13c4021',
password = 'vX34wUk'
)
user_manager.transfer_credit('m94', 'Sky533535', 0.01)
# last_username = user_manager.get_max_user_in_pattern(prefix)
# user_id = f'{prefix}{user_manager.get_generate_username(last_username)}'
# user_pass = user_manager.get_random_password()
# print(user_id)
# print(user_pass)
# user_manager.register_user(
# user_id=user_id,
# user_password=user_pass
# )
# user_manager.logout()
# user_manager = CM_BOT(
# username = user_id,
# password = user_pass
# )
# user_manager.login(user_id, user_pass)
# print(user_manager.get_register_link())
# user_manager.login(
# username = user_id,
# password = user_pass
# )
# user_manager.set_security_pin(user_pass)
print("CM_BOT helper module. Use from service entrypoints instead of running direct debug actions.")
if __name__ == "__main__":
main()
main()

View File

@ -4,17 +4,25 @@ from .cm_bot import CM_BOT
from .db import DB
import secrets, string
import os
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
class CM_BOT_HAL:
def __init__(self):
self.db = DB()
self.prefix = '13c'
self.agent_id = 'cm13a3'
self.agent_password = 'Sky533535'
self.security_pin = 'Sky533535'
self.prefix = _get_required_env('CM_PREFIX_PATTERN')
self.agent_id = _get_required_env('CM_AGENT_ID')
self.agent_password = _get_required_env('CM_AGENT_PASSWORD')
self.security_pin = _get_required_env('CM_SECURITY_PIN')
def get_random_password(self):
length = 8
length = secrets.choice(range(8, 11))
lower_case = string.ascii_lowercase
upper_case = string.ascii_uppercase
digits = string.digits
@ -77,11 +85,15 @@ class CM_BOT_HAL:
password = self.agent_password
) == False:
raise Exception(f'[Fail login] {self.agent_id} cannot login.')
cm_bot.register_user(
user_id = username,
user_password = password
)
cm_bot.logout()
try:
if cm_bot.register_user(
user_id = username,
user_password = password
) is False:
raise Exception(f'[Fail create] {username} creation failed.')
finally:
cm_bot.logout()
cm_bot = CM_BOT()
if cm_bot.login(
@ -197,10 +209,10 @@ class CM_BOT_HAL:
if __name__ == '__main__':
bot = CM_BOT_HAL()
# bot.transfer_credit_api('13c4070', 'zU2QoL', '4753kit', 'Sky533535')
# print(bot.get_next_username('13c'))
# bot.transfer_credit_api('<from_user>', '<from_password>', '<to_user>', '<to_pin>')
# print(bot.get_next_username(bot.prefix))
# print(bot.get_random_password())
# bot.get_user_api()
# bot.insert_user_to_table_acc({'username': 'test0001', 'password': 'test0001', 'link': 'test0001'})
# print(bot.get_whatsapp_link_username('https://chat.whatsapp.com/DZDWcicr6MTFrR4kBfnJXO'))
# print(bot.get_user_pass_from_acc('13c4151'))
# print(bot.get_user_pass_from_acc('<username>'))

View File

@ -1,9 +1,13 @@
import threading, logging, time, asyncio
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
@ -14,12 +18,36 @@ 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> <agent password> <player username> <player password>'
'/3 <agent username> <player username>',
'/9 - Show Chat ID'
]
await update.message.reply_text('\n'.join(menu))
@ -48,12 +76,12 @@ async def set_security_handler(update: Update, context: ContextTypes.DEFAULT_TYP
if len(context.args) == 0 or len(context.args) > 1:
await update.message.reply_text('CMD is wrong, please check and retry ...')
return
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 ...')
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']} !")
@ -61,50 +89,175 @@ async def set_security_handler(update: Update, context: ContextTypes.DEFAULT_TYP
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) != 4:
if len(context.args) == 0 or len(context.args) != 2:
await update.message.reply_text('CMD is wrong, please check and retry ...')
return
bot = CM_BOT_HAL()
f_username, f_password, t_username, t_password = context.args
bot.insert_user_to_table_user(
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': t_password
't_password': security_pin
}
)
await update.message.reply_text(f'Done insert {f_username} into user table.')
if success is False:
raise Exception('Failed to insert user into table')
def monitor_amount_of_available_acc():
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 = CM_BOT_HAL()
available_size = len(bot.get_all_available_acc())
bot = None
try:
bot = CM_BOT_HAL()
available_size = len(bot.get_all_available_acc())
if available_size <= max_available:
global creating_acc_now
creating_acc_now = True
for i in range(available_size, max_available):
bot.create_new_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
time.sleep(10 * 60)
del bot
if bot is not None:
del bot
time.sleep(10 * 60)
def main() -> None:
"""Start the bot."""
# application = Application.builder().token("5327571437:AAFlowwnAysTEMx6LtYQNTevGCboKDZoYzY").build()
application = Application.builder().token("5315819168:AAH31xwNgPdnk123x97XalmTW6fQV5EUCFU").build()
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...")
threading.Thread(target=monitor_amount_of_available_acc, args=()).start()
application.run_polling(allowed_updates=Update.ALL_TYPES)
notifier = TelegramNotifier()
threading.Thread(target=monitor_amount_of_available_acc, args=(notifier,), daemon=True).start()
run_polling_forever(bot_token)
if __name__ == "__main__":
main()

View File

@ -9,7 +9,9 @@ CORS(app)
# API base URL - use environment variable for Docker Compose
import os
API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:3000')
PREFIX_PATTERN = os.getenv('CM_PREFIX_PATTERN', '')
print("API: ", API_BASE_URL)
print("Prefix pattern: ", PREFIX_PATTERN)
# Beautiful HTML template with modern styling
HTML_TEMPLATE = """
@ -379,6 +381,7 @@ HTML_TEMPLATE = """
</div>
<script>
const PREFIX_PATTERN = {{ prefix_pattern|tojson }};
let currentTab = 'acc';
let accData = [];
let userData = [];
@ -440,7 +443,7 @@ HTML_TEMPLATE = """
return;
}
// Sort data with 13c prefix priority
// Sort data with configured prefix priority
const sortedData = sortData([...accData], 'acc');
const table = `
@ -487,7 +490,7 @@ HTML_TEMPLATE = """
return;
}
// Sort data with 13c prefix priority and by update time
// Sort data with configured prefix priority and by update time
const sortedData = sortData([...userData], 'user');
const table = `
@ -544,15 +547,15 @@ HTML_TEMPLATE = """
function sortData(data, type) {
if (type === 'acc') {
return data.sort((a, b) => {
// 13c prefix always on top
const aIs13c = a.username && a.username.startsWith('13c');
const bIs13c = b.username && b.username.startsWith('13c');
// Configured prefix always on top
const aIsPreferred = PREFIX_PATTERN && a.username && a.username.startsWith(PREFIX_PATTERN);
const bIsPreferred = PREFIX_PATTERN && b.username && b.username.startsWith(PREFIX_PATTERN);
if (aIs13c && !bIs13c) return -1;
if (!aIs13c && bIs13c) return 1;
if (aIsPreferred && !bIsPreferred) return -1;
if (!aIsPreferred && bIsPreferred) return 1;
// If both are 13c or both are not 13c, sort by username in descending order
if (aIs13c && bIs13c) {
// If both are preferred or both are not, sort by username in descending order
if (aIsPreferred && bIsPreferred) {
return (b.username || '').localeCompare(a.username || '');
} else {
return (b.username || '').localeCompare(a.username || '');
@ -560,12 +563,12 @@ HTML_TEMPLATE = """
});
} else if (type === 'user') {
return data.sort((a, b) => {
// 13c prefix always on top
const aIs13c = a.f_username && a.f_username.startsWith('13c');
const bIs13c = b.f_username && b.f_username.startsWith('13c');
// Configured prefix always on top
const aIsPreferred = PREFIX_PATTERN && a.f_username && a.f_username.startsWith(PREFIX_PATTERN);
const bIsPreferred = PREFIX_PATTERN && b.f_username && b.f_username.startsWith(PREFIX_PATTERN);
if (aIs13c && !bIs13c) return -1;
if (!aIs13c && bIs13c) return 1;
if (aIsPreferred && !bIsPreferred) return -1;
if (!aIsPreferred && bIsPreferred) return 1;
// Then sort by last_update_time (newest first)
const aTime = new Date(a.last_update_time || 0);
@ -702,7 +705,7 @@ HTML_TEMPLATE = """
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
return render_template_string(HTML_TEMPLATE, prefix_pattern=PREFIX_PATTERN)
@app.route('/api/acc/')
def proxy_acc():
@ -742,4 +745,4 @@ if __name__ == '__main__':
print("Starting CM Web View...")
print("Web interface will be available at: http://localhost:8000")
print("Make sure the API server is running on port 3000")
app.run(host='0.0.0.0', port=8000, debug=True)
app.run(host='0.0.0.0', port=8000, debug=True)

View File

@ -1,32 +1,53 @@
import os
import time
import mysql.connector
from mysql.connector import Error
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
class DB:
def __init__(self):
self.config = {
'host': '192.168.0.210',
'user': 'rex_cm',
'password': 'hengserver',
'database': 'rex_cm',
'port': 3306
'host': _get_required_env('DB_HOST'),
'user': _get_required_env('DB_USER'),
'password': _get_required_env('DB_PASSWORD'),
'database': _get_required_env('DB_NAME'),
'port': int(_get_required_env('DB_PORT')),
'connection_timeout': int(_get_required_env('DB_CONNECTION_TIMEOUT'))
}
self.connect_retries = max(1, int(_get_required_env('DB_CONNECT_RETRIES')))
self.connect_retry_delay = float(_get_required_env('DB_CONNECT_RETRY_DELAY'))
self.init_database()
def get_connection(self):
"""Get MySQL database connection."""
try:
connection = mysql.connector.connect(**self.config)
return connection
except Error as e:
print(f"Error connecting to MySQL: {e}")
return None
for attempt in range(1, self.connect_retries + 1):
try:
connection = mysql.connector.connect(**self.config)
return connection
except Error as e:
print(f"Error connecting to MySQL: {e}")
if attempt < self.connect_retries:
print(
f"Retrying MySQL connection ({attempt}/{self.connect_retries}) "
f"in {self.connect_retry_delay} seconds..."
)
time.sleep(self.connect_retry_delay)
return None
def init_database(self):
"""Initialize the database connection."""
connection = self.get_connection()
if connection is None:
raise Exception("Failed to connect to database")
cursor = None
try:
cursor = connection.cursor()
# Test connection by checking if required tables exist
@ -44,8 +65,9 @@ class DB:
print(f"Error verifying database: {e}")
raise Exception(f"Database verification failed: {e}")
finally:
if connection.is_connected():
if cursor is not None:
cursor.close()
if connection.is_connected():
connection.close()
def query(self, query, params=None):
@ -53,7 +75,7 @@ class DB:
connection = self.get_connection()
if connection is None:
return []
cursor = None
try:
cursor = connection.cursor(dictionary=True)
@ -69,8 +91,9 @@ class DB:
print(f"Error executing query: {e}")
return []
finally:
if connection.is_connected():
if cursor is not None:
cursor.close()
if connection.is_connected():
connection.close()
def execute(self, query, params=None):
@ -78,7 +101,7 @@ class DB:
connection = self.get_connection()
if connection is None:
return False
cursor = None
try:
cursor = connection.cursor()
@ -94,6 +117,7 @@ class DB:
print(f"Error executing query: {e}")
return False
finally:
if connection.is_connected():
if cursor is not None:
cursor.close()
connection.close()
if connection.is_connected():
connection.close()

115
app/telegram_notifier.py Normal file
View File

@ -0,0 +1,115 @@
import logging
import os
from pathlib import Path
from typing import Optional
import requests
logger = logging.getLogger(__name__)
TELEGRAM_API_URL = "https://api.telegram.org"
def _load_env_from_file() -> None:
"""
Load key=value pairs from a .env-style file to populate os.environ.
Existing environment variables take precedence.
"""
candidate_paths = []
env_file = os.getenv("ENV_FILE_PATH")
if env_file:
candidate_paths.append(Path(env_file))
candidate_paths.append(Path(__file__).resolve().parents[1] / ".env")
candidate_paths.append(Path.cwd() / ".env")
for path in candidate_paths:
if not path:
continue
if not path.exists() or not path.is_file():
continue
try:
with path.open("r") as env_file_handle:
for line in env_file_handle:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except OSError as exc:
logger.warning("Unable to load env file %s: %s", path, exc)
_load_env_from_file()
def get_telegram_bot_token() -> str:
"""
Fetch the Telegram bot token from the TELEGRAM_BOT_TOKEN environment variable.
Raises RuntimeError if missing so the application fails fast during startup.
"""
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
_load_env_from_file()
token = os.getenv("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("Missing TELEGRAM_BOT_TOKEN environment variable")
return token
class TelegramNotifier:
"""
Utility wrapper to send proactive notifications to a dedicated Telegram chat.
Requires TELEGRAM_ALERT_CHAT_ID and TELEGRAM_ALERT_BOT_TOKEN (or TELEGRAM_BOT_TOKEN) env vars.
"""
def __init__(self):
self.chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID")
self.bot_token = os.getenv("TELEGRAM_ALERT_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN")
if not self.chat_id:
logger.warning(
"TELEGRAM_ALERT_CHAT_ID environment variable missing; skipping Telegram notification setup"
)
if not self.bot_token:
logger.warning(
"TELEGRAM_ALERT_BOT_TOKEN/TELEGRAM_BOT_TOKEN env var missing; skipping Telegram notification setup"
)
def _can_notify(self) -> bool:
return bool(self.chat_id and self.bot_token)
def _send_message(self, text: str, parse_mode: Optional[str] = None) -> None:
if not self._can_notify():
logger.warning(
"Skipping Telegram notification: bot_token/chat_id not configured. Message: %s",
text,
)
return
url = f"{TELEGRAM_API_URL}/bot{self.bot_token}/sendMessage"
payload = {"chat_id": self.chat_id, "text": text}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
response = requests.post(url, json=payload, timeout=10)
if response.status_code != 200:
logger.error(
"Failed to send Telegram notification. Status: %s Body: %s",
response.status_code,
response.text,
)
except requests.RequestException as exc:
logger.exception("Error sending Telegram notification: %s", exc)
def notify_login_failure(self, details: str) -> None:
message = f"⚠️ CM Telegram Bot login issue detected.\nDetails: {details}\nAutomatic account creation paused until the next retry window."
self._send_message(message)
def notify_generic_error(self, details: str) -> None:
message = f"⚠️ CM Telegram Bot encountered an issue:\n{details}"
self._send_message(message)

View File

@ -23,7 +23,7 @@ services:
dockerfile: docker/transfer/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-transfer:${DOCKER_IMAGE_TAG:-dev}"
environment:
- API_BASE_URL=http://api-server:3000
- CM_TRANSFER_MAX_THREADS=1
API_BASE_URL: http://api-server:3000
CM_TRANSFER_MAX_THREADS: "1"
mem_limit: 2g
cpus: 2

View File

@ -2,71 +2,110 @@ services:
# Telegram Bot Service
telegram-bot:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-telegram:${DOCKER_IMAGE_TAG:-latest}"
container_name: cm-telegram-bot
container_name: ${CM_DEPLOY_NAME:-cm}-telegram-bot
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
PYTHONUNBUFFERED: "1"
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ALERT_CHAT_ID: ${TELEGRAM_ALERT_CHAT_ID:-}
TELEGRAM_ALERT_BOT_TOKEN: ${TELEGRAM_ALERT_BOT_TOKEN:-}
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
CM_AGENT_ID: ${CM_AGENT_ID}
CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD}
CM_SECURITY_PIN: ${CM_SECURITY_PIN}
CM_BOT_BASE_URL: ${CM_BOT_BASE_URL}
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_PORT: ${DB_PORT}
DB_CONNECTION_TIMEOUT: ${DB_CONNECTION_TIMEOUT}
DB_CONNECT_RETRIES: ${DB_CONNECT_RETRIES}
DB_CONNECT_RETRY_DELAY: ${DB_CONNECT_RETRY_DELAY}
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- cm-network
- bot-network
depends_on:
- api-server
# API Server Service
api-server:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-api:${DOCKER_IMAGE_TAG:-latest}"
container_name: cm-api-server
container_name: ${CM_DEPLOY_NAME:-cm}-api-server
restart: unless-stopped
ports:
- "3000:3000"
- "3000"
environment:
- PYTHONUNBUFFERED=1
PYTHONUNBUFFERED: "1"
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_PORT: ${DB_PORT}
DB_CONNECTION_TIMEOUT: ${DB_CONNECTION_TIMEOUT}
DB_CONNECT_RETRIES: ${DB_CONNECT_RETRIES}
DB_CONNECT_RETRY_DELAY: ${DB_CONNECT_RETRY_DELAY}
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- cm-network
- bot-network
# Web View Service
web-view:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web:${DOCKER_IMAGE_TAG:-latest}"
container_name: cm-web-view
container_name: ${CM_DEPLOY_NAME:-cm}-web-view
restart: unless-stopped
ports:
- "8001:8000"
- "${CM_WEB_HOST_PORT:-8001}:8000"
environment:
- PYTHONUNBUFFERED=1
- API_BASE_URL=http://api-server:3000
PYTHONUNBUFFERED: "1"
API_BASE_URL: http://api-server:3000
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- cm-network
- bot-network
depends_on:
- api-server
transfer-bot:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-transfer:${DOCKER_IMAGE_TAG:-latest}"
container_name: cm-transfer-bot
container_name: ${CM_DEPLOY_NAME:-cm}-transfer-bot
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
- API_BASE_URL=http://api-server:3000
- CM_TRANSFER_MAX_THREADS=20
PYTHONUNBUFFERED: "1"
API_BASE_URL: http://api-server:3000
CM_TRANSFER_MAX_THREADS: "20"
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
CM_AGENT_ID: ${CM_AGENT_ID}
CM_AGENT_PASSWORD: ${CM_AGENT_PASSWORD}
CM_SECURITY_PIN: ${CM_SECURITY_PIN}
CM_BOT_BASE_URL: ${CM_BOT_BASE_URL}
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_PORT: ${DB_PORT}
DB_CONNECTION_TIMEOUT: ${DB_CONNECTION_TIMEOUT}
DB_CONNECT_RETRIES: ${DB_CONNECT_RETRIES}
DB_CONNECT_RETRY_DELAY: ${DB_CONNECT_RETRY_DELAY}
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
mem_limit: 6g
cpus: 2
networks:
- cm-network
- bot-network
depends_on:
- api-server
- web-view
networks:
cm-network:
bot-network:
name: ${CM_DEPLOY_NAME:-cm}-network
driver: bridge

28
envs/rex/.env Normal file
View File

@ -0,0 +1,28 @@
# === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=5315819168:AAH31xwNgPdnk123x97XalmTW6fQV5EUCFU
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=rex_cm
DB_PASSWORD=hengserver
DB_NAME=rex_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=cm13a3
CM_AGENT_PASSWORD=Sky533535
CM_SECURITY_PIN=Sky533535
CM_BOT_BASE_URL=https://cm99.net

28
envs/siong/.env Normal file
View File

@ -0,0 +1,28 @@
# === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=7028479329:AAH_UTPoYcaB0iZMXJjO7pKYxyub8ZSXn2E
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=siong_cm
DB_PASSWORD=hengserver
DB_NAME=siong_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13sa
CM_AGENT_ID=cm13a39
CM_AGENT_PASSWORD=Wenwen12345
CM_SECURITY_PIN=Wenwen12345
CM_BOT_BASE_URL=https://cm99.net