v2.5.0: add key disable switches and pro UI polish

This commit is contained in:
Виталий Литвинов
2026-04-25 09:44:53 +03:00
parent 6b89c3ea81
commit e54778c08c
17 changed files with 683 additions and 136 deletions

View File

@@ -1,4 +1,4 @@
# GoTelegram v2.5.0 Bot
# goTelegram Pro v2.5.0 Bot
Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux servers.
@@ -67,7 +67,7 @@ For systemd service:
```bash
[Unit]
Description=GoTelegram Bot
Description=goTelegram Pro Bot
After=network.target
[Service]
@@ -146,4 +146,4 @@ code, stdout, stderr = await sh("command", "arg1", "arg2")
## License
GoTelegram v2.5.0 - Open source community project
goTelegram Pro v2.5.0 - Open source community project

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
GoTelegram v2.5.0 Bot - MTProxy Management for Linux
goTelegram Pro v2.5.0 Bot - MTProxy Management for Linux
Manages telemt engine via Telegram interface with full CLI feature parity
Uses python-telegram-bot v21+
Supports EN/RU UI with per-user language preferences.
@@ -103,6 +103,7 @@ logger = logging.getLogger(__name__)
GOTELEGRAM_VERSION = "2.5.0"
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json"
TELEMT_CONFIG = "/etc/telemt/config.toml"
TELEMT_SERVICE = "telemt"
WEBSITE_ROOT = "/var/www/gotelegram-site"
@@ -113,6 +114,7 @@ INSTALL_SH = "/opt/gotelegram/install.sh"
PROMO_LINK_1 = "https://vk.cc/ct29NQ"
PROMO_LINK_2 = "https://vk.cc/cUxAhj"
TIP_LINK = "https://pay.cloudtips.ru/p/7410814f"
YOUTUBE_LINK = os.getenv("GOTELEGRAM_YOUTUBE_LINK", "").strip()
PROMO_STAMP_FILE = "/opt/gotelegram/.promo_bot_last_shown"
BOT_TOKEN = os.getenv("BOT_TOKEN")
@@ -1412,6 +1414,48 @@ def load_telemt_users() -> Dict[str, str]:
}
def load_disabled_users() -> Dict[str, str]:
raw = load_json(DISABLED_USERS_FILE) or {}
if not isinstance(raw, dict):
return {}
users = raw.get("users") if isinstance(raw.get("users"), dict) else raw
if not isinstance(users, dict):
return {}
clean: Dict[str, str] = {}
for name, secret in users.items():
if name in {"version", "updated_at"}:
continue
name_s = str(name).strip()
secret_s = str(secret or "").strip()
if _USER_NAME_RE.match(name_s) and secret_s:
clean[name_s] = secret_s
return clean
def save_disabled_users(users: Dict[str, str]) -> bool:
payload = {
"version": 1,
"updated_at": datetime.utcnow().isoformat() + "Z",
"users": {name: users[name] for name in sorted(users)},
}
ok = save_json(DISABLED_USERS_FILE, payload)
if ok:
try:
os.chmod(DISABLED_USERS_FILE, 0o600)
except OSError:
pass
return ok
def load_user_records() -> Dict[str, Dict[str, Any]]:
records: Dict[str, Dict[str, Any]] = {}
for name, secret in load_disabled_users().items():
records[name] = {"secret": secret, "enabled": False}
for name, secret in load_telemt_users().items():
records[name] = {"secret": secret, "enabled": True}
return records
def save_telemt_users(users: Dict[str, str]) -> bool:
"""Persist [access.users] while keeping the rest of the TOML structure."""
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
@@ -1589,10 +1633,12 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
# ============================================================================
def _users_keyboard(users: Dict[str, str], user_id: Optional[int]) -> InlineKeyboardMarkup:
def _users_keyboard(users: Dict[str, Dict[str, Any]], user_id: Optional[int]) -> InlineKeyboardMarkup:
rows = []
for name in sorted(users):
rows.append([InlineKeyboardButton(f"👤 {name}", callback_data=f"user_view_{name}")])
for name in sorted(users, key=lambda item: (item != "main", item)):
enabled = bool(users[name].get("enabled"))
icon = "🟢" if enabled else ""
rows.append([InlineKeyboardButton(f"{icon} {name}", callback_data=f"user_view_{name}")])
rows.append([InlineKeyboardButton(" Добавить ключ", callback_data="user_add")])
rows.append([
InlineKeyboardButton(_t(user_id, "btn_refresh"), callback_data="menu_users"),
@@ -1605,10 +1651,13 @@ async def cb_menu_users(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
query = update.callback_query
await query.answer()
user_id = _uid(update)
users = load_telemt_users()
users = load_user_records()
if users:
user_lines = "\n".join(f"• <code>{html.escape(name)}</code>" for name in sorted(users))
user_lines = "\n".join(
f"{'🟢' if users[name].get('enabled') else ''} <code>{html.escape(name)}</code>"
for name in sorted(users, key=lambda item: (item != "main", item))
)
else:
user_lines = "<i>Ключей пока нет</i>"
@@ -1635,9 +1684,9 @@ async def cb_menu_users(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
await safe_edit_message(query, text, reply_markup=_users_keyboard(users, user_id), parse_mode="HTML")
async def _user_detail_text(name: str, secret: str) -> str:
async def _user_detail_text(name: str, secret: str, enabled: bool = True) -> str:
link = await get_proxy_link_for_secret(secret)
api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}")
api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}") if enabled else None
details = ""
if api:
data = api.get("data", api)
@@ -1656,12 +1705,16 @@ async def _user_detail_text(name: str, secret: str) -> str:
else:
compact = json.dumps(data, ensure_ascii=False)[:600]
details = f"\n<pre>{html.escape(compact)}</pre>"
elif enabled:
details = "\n<i>Runtime API недоступен. Новые установки goTelegram Pro включают его автоматически.</i>"
else:
details = "\n<i>Runtime API недоступен. Новые установки GoTelegram включают его автоматически.</i>"
details = "\n<i>Ключ отключён и сейчас не принимается telemt.</i>"
link_line = html.escape(link) if link else "link unavailable"
status_line = "🟢 enabled" if enabled else "⏸ disabled"
return (
f"<b>👤 {html.escape(name)}</b>\n\n"
f"Status: <b>{status_line}</b>\n"
f"Secret: <code>{html.escape(secret)}</code>\n\n"
f"<b>Ссылка:</b>\n<code>{link_line}</code>\n"
f"{details}"
@@ -1673,23 +1726,31 @@ async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_view_")
users = load_telemt_users()
secret = users.get(name)
if not secret:
users = load_user_records()
record = users.get(name)
if not record:
await safe_edit_message(
query,
"❌ Пользователь не найден.",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")]]),
)
return
enabled = bool(record.get("enabled"))
secret = str(record.get("secret", ""))
buttons = [
[InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")],
[InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
]
if name == "main":
buttons = [
[InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")],
[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")],
]
await safe_edit_message(
query,
await _user_detail_text(name, secret),
await _user_detail_text(name, secret, enabled),
reply_markup=InlineKeyboardMarkup(buttons),
parse_mode="HTML",
disable_web_page_preview=True,
@@ -1714,6 +1775,45 @@ async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
)
async def cb_user_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_toggle_")
if name == "main":
await query.answer("main нельзя отключить", show_alert=True)
return
active = load_telemt_users()
disabled = load_disabled_users()
records = load_user_records()
record = records.get(name)
if not record:
await query.answer("Ключ не найден", show_alert=True)
return
enabled = not bool(record.get("enabled"))
secret = str(record.get("secret", ""))
if enabled:
disabled.pop(name, None)
active[name] = secret
else:
active.pop(name, None)
disabled[name] = secret
if enabled:
saved = save_telemt_users(active) and save_disabled_users(disabled)
else:
saved = save_disabled_users(disabled) and save_telemt_users(active)
if not saved:
await safe_edit_message(query, "Не удалось сохранить состояние ключа")
return
await refresh_telemt_after_user_change()
await safe_edit_message(
query,
f"{'✅ Ключ включён' if enabled else '⏸ Ключ отключён'}: <code>{html.escape(name)}</code>",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data=f"user_view_{name}")]]),
parse_mode="HTML",
)
async def cb_user_delete(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
@@ -1735,12 +1835,15 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T
await query.answer()
user_id = _uid(update)
name = query.data.removeprefix("user_del_yes_")
users = load_telemt_users()
if name == "main" or name not in users:
active = load_telemt_users()
disabled = load_disabled_users()
records = load_user_records()
if name == "main" or name not in records:
await query.answer("Нельзя удалить этот ключ", show_alert=True)
return
users.pop(name, None)
if not save_telemt_users(users):
active.pop(name, None)
disabled.pop(name, None)
if not save_telemt_users(active) or not save_disabled_users(disabled):
await safe_edit_message(query, "Не удалось сохранить config.toml")
return
await refresh_telemt_after_user_change()
@@ -1757,10 +1860,11 @@ async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TY
if not _USER_NAME_RE.match(name):
await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.")
return
users = load_telemt_users()
if name in users:
records = load_user_records()
if name in records:
await update.message.reply_text("❌ Такой пользователь уже есть.")
return
users = load_telemt_users()
secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32]
users[name] = secret
if not save_telemt_users(users):
@@ -1773,7 +1877,7 @@ async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TY
f"Пользователь: <code>{html.escape(name)}</code>\n"
f"Secret: <code>{secret}</code>\n\n"
f"<code>{html.escape(link or '')}</code>",
reply_markup=_users_keyboard(load_telemt_users(), user_id),
reply_markup=_users_keyboard(load_user_records(), user_id),
parse_mode="HTML",
disable_web_page_preview=True,
)
@@ -2332,8 +2436,8 @@ async def cmd_deladmin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
def get_promo_text() -> str:
"""Return promo text with 2 hosters + donate."""
return (
"""Return promo text with 2 hosters, optional YouTube link and donate."""
text = (
"<b>💰 Хостинг #1 — скидка до 60%</b>\n"
f"<a href='{PROMO_LINK_1}'>{PROMO_LINK_1}</a>\n\n"
"<b>Промокоды:</b>\n"
@@ -2349,6 +2453,13 @@ def get_promo_text() -> str:
"<b>☕ Донат / Чаевые</b>\n"
f"<a href='{TIP_LINK}'>{TIP_LINK}</a>"
)
if YOUTUBE_LINK:
text += (
"\n\n━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"<b>▶ YouTube-канал</b>\n"
f"<a href='{html.escape(YOUTUBE_LINK)}'>{html.escape(YOUTUBE_LINK)}</a>"
)
return text
def should_show_promo_bot() -> bool:
@@ -2393,7 +2504,7 @@ async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
text = (
f"<b> Credits & Acknowledgements</b>\n\n"
f"<b>GoTelegram v{GOTELEGRAM_VERSION}</b>\n\n"
f"<b>goTelegram Pro v{GOTELEGRAM_VERSION}</b>\n\n"
f"Built with love for the Telegram community\n\n"
f"<b>Special thanks to:</b>\n\n"
f"🙏 <b>telemt</b> - MTProxy engine\n"
@@ -2405,7 +2516,7 @@ async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
f"🚀 <b>Start Bootstrap</b> - Bootstrap templates\n"
f" Professional design framework\n\n"
f"💬 <b>Community</b> - Your feedback & support\n\n"
f"<i>GoTelegram is open-source and community-driven</i>"
f"<i>goTelegram Pro is open-source and community-driven</i>"
)
keyboard = InlineKeyboardMarkup(
@@ -2425,7 +2536,7 @@ async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer()
text = (
"<b>⚠️ Remove GoTelegram</b>\n\n"
"<b>⚠️ Remove goTelegram Pro</b>\n\n"
"This will completely remove the installation.\n"
"Are you sure?"
)
@@ -2443,7 +2554,7 @@ async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE)
query = update.callback_query
await query.answer()
await safe_edit_message(query,"⏳ Removing GoTelegram...")
await safe_edit_message(query,"⏳ Removing goTelegram Pro...")
# Stop service
await sh("systemctl", "stop", TELEMT_SERVICE)
@@ -2452,7 +2563,7 @@ async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE)
for path in ["/opt/gotelegram", WEBSITE_ROOT]:
await sh("rm", "-rf", path)
text = "GoTelegram removed successfully"
text = "goTelegram Pro removed successfully"
keyboard = InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
)
@@ -2617,6 +2728,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await cb_user_add(update, context)
elif data.startswith("user_view_"):
await cb_user_view(update, context)
elif data.startswith("user_toggle_"):
await cb_user_toggle(update, context)
elif data.startswith("user_del_yes_"):
await cb_user_delete_confirm(update, context)
elif data.startswith("user_del_"):
@@ -2663,7 +2776,7 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
if not ok:
await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
return
# Success — record in GoTelegram config. Use "template_id" (canonical
# Success — record in goTelegram Pro config. Use "template_id" (canonical
# field name written by install.sh/save_gotelegram_config).
config = load_json(GOTELEGRAM_CONFIG) or {}
config["template_id"] = tpl_id
@@ -2734,7 +2847,7 @@ def main() -> None:
application.add_error_handler(error_handler)
# Run the bot
logger.info(f"GoTelegram v{GOTELEGRAM_VERSION} bot starting...")
logger.info(f"goTelegram Pro v{GOTELEGRAM_VERSION} bot starting...")
application.run_polling(allowed_updates=Update.ALL_TYPES)

View File

@@ -1,4 +1,4 @@
# GoTelegram v2.5.0 Bot Configuration
# goTelegram Pro v2.5.0 Bot Configuration
# Copy this to .env and fill in your values
# Telegram Bot Token from @BotFather

View File

@@ -1,5 +1,5 @@
"""
GoTelegram v2.5.0 Bot — i18n module
goTelegram Pro v2.5.0 Bot — i18n module
Provides per-user language preferences and a simple t()/tf() API.
Usage:
@@ -40,12 +40,12 @@ def _detect_default_lang() -> str:
if isinstance(data, dict):
candidates.extend([data.get("language"), data.get("lang")])
except Exception as e:
logger.warning("failed to read GoTelegram language config: %s", e)
logger.warning("failed to read goTelegram Pro language config: %s", e)
try:
if GOTELEGRAM_LANG_MARKER.exists():
candidates.append(GOTELEGRAM_LANG_MARKER.read_text(encoding="utf-8").strip()[:2])
except Exception as e:
logger.warning("failed to read GoTelegram language marker: %s", e)
logger.warning("failed to read goTelegram Pro language marker: %s", e)
candidates.append(os.getenv("BOT_LANG", ""))
for raw in candidates:
code = str(raw or "").strip().lower()

View File

@@ -6,7 +6,7 @@
"lang_saved": "Language saved: %s",
"lang_choose": "Choose your language:",
"welcome_title": "GoTelegram v%s",
"welcome_title": "goTelegram Pro v%s",
"welcome_subtitle": "🤖 MTProxy Management Bot",
"welcome_powered": "Powered by telemt engine",
"welcome_prompt": "Select an action from the menu below:",
@@ -17,7 +17,7 @@
"btn_no": "❌ No",
"access_denied": "⛔ Access denied.\nYour ID: <code>%s</code>",
"help_title": "GoTelegram Bot — Commands",
"help_title": "goTelegram Pro Bot — Commands",
"help_lines": "/start — Main menu\n/help — This help\n/status — Quick status\n/logs — Latest logs\n/lang — Change language\n/addadmin ID — Add admin\n/deladmin ID — Remove admin\n\nUse the menu buttons for other operations.",
"menu_install": "⚙️ Install",

View File

@@ -6,7 +6,7 @@
"lang_saved": "Язык сохранён: %s",
"lang_choose": "Выберите язык:",
"welcome_title": "GoTelegram v%s",
"welcome_title": "goTelegram Pro v%s",
"welcome_subtitle": "🤖 Бот управления MTProxy",
"welcome_powered": "На базе движка telemt",
"welcome_prompt": "Выберите действие в меню ниже:",
@@ -17,7 +17,7 @@
"btn_no": "❌ Нет",
"access_denied": "⛔ Доступ запрещён.\nВаш ID: <code>%s</code>",
"help_title": "GoTelegram Bot — Команды",
"help_title": "goTelegram Pro Bot — Команды",
"help_lines": "/start — Главное меню\n/help — Эта справка\n/status — Быстрый статус\n/logs — Последние логи\n/lang — Сменить язык\n/addadmin ID — Добавить админа\n/deladmin ID — Удалить админа\n\nИспользуйте кнопки меню для остальных операций.",
"menu_install": "⚙️ Установить",