Initial commit: Electrum Telegram wallet bot with Tor support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-04-26 16:44:21 +00:00
commit e6f86843df
14 changed files with 1653 additions and 0 deletions
+919
View File
@@ -0,0 +1,919 @@
#!/usr/bin/env python3
import io
import json
import logging
import os
import subprocess
from decimal import Decimal, InvalidOperation
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ConversationHandler,
ContextTypes,
MessageHandler,
filters,
)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
ALLOWED_USERS_FILE = os.environ.get("ALLOWED_USERS_FILE", "/app/allowed_users.txt")
WALLETS_BASE = "/root/.electrum/wallets"
SETTINGS_FILE = "/root/.electrum/bot_settings.json"
# ── Translations ──────────────────────────────────────────────────────────────
STRINGS: dict[str, dict[str, str]] = {
"en": {
"welcome": (
"Welcome to Electrum Wallet Bot! 🔐\n\n"
"This bot lets you manage Bitcoin watch-only wallets using Electrum on your own server. "
"Private keys never leave your offline device.\n\n"
"What you can do:\n"
"• Restore a wallet from its master public key (xpub)\n"
"• Check balance in BTC, mBTC, or sats\n"
"• Get an unused receiving address\n"
"• Load / close wallets in the Electrum daemon\n"
"• Check network fee recommendations\n"
"• Create unsigned transactions for air-gapped signing\n"
"• Broadcast signed transactions to the network\n\n"
"Use the buttons below to get started."
),
"not_authorized": "You are not authorized to use this bot.",
"cancelled": "Cancelled.",
"cancel_for_button": "Previous operation cancelled. Tap the button again.",
"your_wallets": "Your wallets:",
"no_wallets": "No wallets found.",
"no_wallets_restore": "No wallets found. Use 'Add Wallet' to add one.",
# Add Wallet
"add_wallet_intro": (
" <b>Add Online (Watch-Only) Wallet</b>\n\n"
"This adds a <b>watch-only</b> wallet using your master public key (xpub). "
"Your private keys <b>never leave your offline device</b> — the bot can only "
"view balances, generate addresses, and create <i>unsigned</i> transactions "
"for you to sign offline.\n\n"
"You will need:\n"
"• A wallet name\n"
"• Your master public key (xpub / zpub / ypub)\n"
"• A password to encrypt the wallet file\n\n"
"Let's start — enter a name for this wallet:"
),
"enter_password": "🔑 Set a password for this wallet:\n\n"
"Requirements: at least 8 characters, one uppercase letter, one lowercase letter, and one number.",
"password_invalid": "❌ Password does not meet requirements.\n\n"
"It must be at least 8 characters and contain at least one uppercase letter, "
"one lowercase letter, and one number.\n\nTry again:",
"enter_xpub": "Enter master public key (xpub / zpub / ypub):",
"enter_name": "Enter wallet name:",
"restoring": "Restoring '{name}'...",
"wallet_restored": "✅ Wallet '{name}' added successfully. Use Load to activate it.",
# Wallet not loaded
"wallet_not_loaded": "⚠️ No wallet is loaded.\n\nPlease use <b>Load</b> to load a wallet first.",
# Load / Close
"select_wallet": "Select wallet:",
"wallet_enter_password": "Wallet: {name}\nEnter password:",
"wallet_loaded": "Wallet '{name}' loaded and selected successfully.",
"wallet_closed": "Wallet '{name}' closed successfully.",
# Balance
"balance_header": "Balance ({unit})",
"confirmed": "Confirmed:",
"unconfirmed": "Unconfirmed:",
"unmatured": "Unmatured:",
# Send
"send_menu": "Send menu:",
"fee_source": "Select fee source:",
"recommended_fee": "Recommended fee: {tip} ({desc})",
"enter_feerate": "Enter fee rate (sat/vB):",
"enter_addr": "Enter recipient address:",
"enter_amount": "Enter amount ({unit}):",
"invalid_amount": "Invalid amount: {error}\nTry again:",
"creating_tx": "Creating unsigned transaction...",
"unsigned_tx_header": "🔐 <b>Unsigned Transaction</b>",
"signing_instructions": (
"Sign this with your offline wallet, then use Broadcast.\n\n"
"📱 Android (Electrum):\n"
"Send → Paste from clipboard\n\n"
"💻 Computer (Electrum):\n"
"Save the file → Send → wrench & screwdriver icon → Read text from file"
),
"enter_signed_tx": "Paste your signed transaction:",
"broadcast_success": "✅ Broadcast successful!\n\nTXID:",
# Send submenu buttons
"btn_check_fee": "Check Mempool Fee",
"btn_send_tx": "Send",
"btn_broadcast": "Broadcast",
"btn_send_max": "Send Max (all)",
# Fee display
"fee_mempool_msg": (
"mempool.space fees:\n"
"⚡ Fastest: {fastest} sat/vB\n"
"🕐 30 min: {half_hour} sat/vB\n"
"🕑 1 hour: {hour} sat/vB\n"
"💰 Economy: {economy} sat/vB\n"
"📉 Minimum: {minimum} sat/vB"
),
# Language
"language_select": "Select language:",
"language_changed": "Language changed to English 🇬🇧",
# Generic
"error": "Error:\n{error}",
"unknown_op": "Unknown operation.",
"done": "Done.",
},
"fa": {
"welcome": (
"به ربات کیف پول الکترام خوش آمدید! 🔐\n\n"
"این ربات مدیریت کیف پول‌های بیتکوین را از طریق الکترام روی سرور شخصی شما فراهم می‌کند. "
"کلیدهای خصوصی هرگز دستگاه آفلاین شما را ترک نمی‌کنند.\n\n"
"امکانات:\n"
"• بازیابی کیف پول از کلید عمومی اصلی (xpub)\n"
"• مشاهده موجودی به BTC، mBTC یا ساتوشی\n"
"• دریافت آدرس استفاده نشده\n"
"• بارگذاری و بستن کیف پول در Electrum daemon\n"
"• بررسی کارمزد شبکه\n"
"• ایجاد تراکنش بدون امضا برای امضای آفلاین\n"
"• ارسال تراکنش امضا شده به شبکه\n\n"
"از دکمه‌های زیر شروع کنید."
),
"not_authorized": "شما مجاز به استفاده از این ربات نیستید.",
"cancelled": "لغو شد.",
"cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.",
"your_wallets": "کیف پول‌های شما:",
"no_wallets": "کیف پولی یافت نشد.",
"no_wallets_restore": "کیف پولی یافت نشد. از 'Add Wallet' برای افزودن استفاده کنید.",
# Add Wallet
"add_wallet_intro": (
"➕ <b>افزودن کیف پول آنلاین (فقط مشاهده)</b>\n\n"
"این کیف پول با استفاده از کلید عمومی اصلی (xpub) شما ایجاد می‌شود. "
"کلیدهای خصوصی <b>هرگز دستگاه آفلاین شما را ترک نمی‌کنند</b> — ربات فقط می‌تواند "
"موجودی را مشاهده کند، آدرس تولید کند و تراکنش‌های <i>بدون امضا</i> ایجاد کند.\n\n"
"به موارد زیر نیاز دارید:\n"
"• نام کیف پول\n"
"• کلید عمومی اصلی (xpub / zpub / ypub)\n"
"• رمز عبور برای رمزگذاری فایل کیف پول\n\n"
"بیایید شروع کنیم — نامی برای این کیف پول وارد کنید:"
),
"enter_password": "🔑 رمز عبور این کیف پول را تعیین کنید:\n\n"
"الزامات: حداقل ۸ کاراکتر، یک حرف بزرگ، یک حرف کوچک و یک عدد.",
"password_invalid": "❌ رمز عبور الزامات را برآورده نمی‌کند.\n\n"
"باید حداقل ۸ کاراکتر داشته باشد و شامل حداقل یک حرف بزرگ، "
"یک حرف کوچک و یک عدد باشد.\n\nدوباره امتحان کنید:",
"enter_xpub": "کلید عمومی اصلی (xpub / zpub / ypub) را وارد کنید:",
"enter_name": "نام کیف پول را وارد کنید:",
"restoring": "در حال افزودن '{name}'...",
"wallet_restored": "✅ کیف پول '{name}' با موفقیت افزوده شد. برای فعال‌سازی از Load استفاده کنید.",
# Wallet not loaded
"wallet_not_loaded": "⚠️ هیچ کیف پولی بارگذاری نشده است.\n\nلطفاً ابتدا از <b>Load</b> برای بارگذاری کیف پول استفاده کنید.",
# Load / Close
"select_wallet": "کیف پول را انتخاب کنید:",
"wallet_enter_password": "کیف پول: {name}\nرمز عبور را وارد کنید:",
"wallet_loaded": "کیف پول '{name}' با موفقیت بارگذاری و انتخاب شد.",
"wallet_closed": "کیف پول '{name}' با موفقیت بسته شد.",
# Balance
"balance_header": "موجودی ({unit})",
"confirmed": "تأیید شده:",
"unconfirmed": "تأیید نشده:",
"unmatured": "بلوغ نیافته:",
# Send
"send_menu": "منوی ارسال:",
"fee_source": "منبع کارمزد را انتخاب کنید:",
"recommended_fee": "کارمزد پیشنهادی: {tip} ({desc})",
"enter_feerate": "نرخ کارمزد را وارد کنید (sat/vB):",
"enter_addr": "آدرس گیرنده را وارد کنید:",
"enter_amount": "مقدار ({unit}) را وارد کنید:",
"invalid_amount": "مقدار نامعتبر: {error}\nدوباره وارد کنید:",
"creating_tx": "در حال ایجاد تراکنش بدون امضا...",
"unsigned_tx_header": "🔐 <b>تراکنش بدون امضا</b>",
"signing_instructions": (
"این تراکنش را با کیف پول آفلاین خود امضا کنید، سپس از Broadcast استفاده کنید.\n\n"
"📱 اندروید (الکترام):\n"
"Send → Paste from clipboard\n\n"
"💻 کامپیوتر (الکترام):\n"
"فایل را ذخیره کنید → Send → آیکون آچار و پیچ‌گوشتی → Read text from file"
),
"enter_signed_tx": "تراکنش امضا شده را الصاق کنید:",
"broadcast_success": "✅ تراکنش با موفقیت ارسال شد!\n\nشناسه تراکنش:",
# Send submenu buttons
"btn_check_fee": "بررسی کارمزد",
"btn_send_tx": "ارسال",
"btn_broadcast": "انتشار",
"btn_send_max": "ارسال حداکثر (همه)",
# Fee display
"fee_mempool_msg": (
"کارمزدهای mempool.space:\n"
"⚡ سریع‌ترین: {fastest} sat/vB\n"
"🕐 ۳۰ دقیقه: {half_hour} sat/vB\n"
"🕑 ۱ ساعت: {hour} sat/vB\n"
"💰 اقتصادی: {economy} sat/vB\n"
"📉 حداقل: {minimum} sat/vB"
),
# Language
"language_select": "زبان را انتخاب کنید:",
"language_changed": "زبان به فارسی تغییر یافت 🇮🇷",
# Generic
"error": "خطا:\n{error}",
"unknown_op": "عملیات نامشخص.",
"done": "انجام شد.",
},
}
def t(user_id: int, key: str, **kwargs) -> str:
lang = get_user_lang(user_id)
text = STRINGS.get(lang, STRINGS["en"]).get(key) or STRINGS["en"].get(key, key)
try:
return text.format(**kwargs) if kwargs else text
except KeyError:
return text
# ── Settings persistence ──────────────────────────────────────────────────────
def _load_settings() -> dict:
try:
with open(SETTINGS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_settings(settings: dict) -> None:
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings, f)
def get_user_unit(user_id: int) -> str:
return _load_settings().get(str(user_id), {}).get("unit", "BTC")
def set_user_unit(user_id: int, unit: str) -> None:
s = _load_settings()
s.setdefault(str(user_id), {})["unit"] = unit
_save_settings(s)
def get_user_lang(user_id: int) -> str:
return _load_settings().get(str(user_id), {}).get("lang", "en")
def set_user_lang(user_id: int, lang: str) -> None:
s = _load_settings()
s.setdefault(str(user_id), {})["lang"] = lang
_save_settings(s)
# ── Unit conversion ───────────────────────────────────────────────────────────
def btc_to_unit(btc_str: str, unit: str) -> str:
try:
btc = Decimal(btc_str)
except InvalidOperation:
return f"{btc_str} {unit}"
if unit == "BTC":
return f"{btc:.8f} BTC"
if unit == "mBTC":
return f"{btc * 1000:.5f} mBTC"
return f"{int(btc * Decimal('100000000')):,} sats"
def unit_to_btc(amount_str: str, unit: str) -> str:
try:
amount = Decimal(amount_str.replace(",", ""))
except InvalidOperation:
raise ValueError(amount_str)
if unit == "BTC":
return f"{amount:.8f}"
if unit == "mBTC":
return f"{amount / Decimal('1000'):.8f}"
return f"{amount / Decimal('100000000'):.8f}"
def validate_password(password: str) -> bool:
if len(password) < 8:
return False
if not any(c.isupper() for c in password):
return False
if not any(c.islower() for c in password):
return False
if not any(c.isdigit() for c in password):
return False
return True
def get_loaded_wallet(user_id: int) -> str | None:
return _load_settings().get(str(user_id), {}).get("loaded_wallet")
def set_loaded_wallet(user_id: int, name: str | None) -> None:
s = _load_settings()
s.setdefault(str(user_id), {})["loaded_wallet"] = name
_save_settings(s)
def format_balance(data: dict, unit: str, lang: str) -> str:
s = STRINGS.get(lang, STRINGS["en"])
confirmed = data.get("confirmed", "0") or "0"
unconfirmed = data.get("unconfirmed", "0") or "0"
unmatured = data.get("unmatured", "0") or "0"
lines = [s["balance_header"].format(unit=unit)]
lines.append(f"{s['confirmed']} {btc_to_unit(confirmed, unit)}")
if Decimal(unconfirmed) != 0:
lines.append(f"{s['unconfirmed']} {btc_to_unit(unconfirmed, unit)}")
if Decimal(unmatured) != 0:
lines.append(f"{s['unmatured']} {btc_to_unit(unmatured, unit)}")
return "\n".join(lines)
# ── Keyboards ─────────────────────────────────────────────────────────────────
MAIN_KEYBOARD = ReplyKeyboardMarkup(
[["Add Wallet", "List"], ["Balance", "Receive"], ["Load", "Close"], ["Send"], ["🌐 Language"]],
resize_keyboard=True,
is_persistent=True,
)
def submenu_kb(user_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton(t(user_id, "btn_check_fee"), callback_data="check_fee")],
[InlineKeyboardButton(t(user_id, "btn_send_tx"), callback_data="do_send")],
[InlineKeyboardButton(t(user_id, "btn_broadcast"), callback_data="do_broadcast")],
])
def fee_kb(user_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[
InlineKeyboardButton("Electrum", callback_data="fee_electrum"),
InlineKeyboardButton("mempool.space", callback_data="fee_mempool"),
]])
def amount_kb(user_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[
InlineKeyboardButton(t(user_id, "btn_send_max"), callback_data="send_max"),
]])
LANG_KB = InlineKeyboardMarkup([[
InlineKeyboardButton("🇬🇧 English", callback_data="lang:en"),
InlineKeyboardButton("🇮🇷 فارسی", callback_data="lang:fa"),
]])
def unit_keyboard(current: str) -> InlineKeyboardMarkup:
def btn(u: str) -> InlineKeyboardButton:
return InlineKeyboardButton(f"{u}" if u == current else u, callback_data=f"unit:{u}")
return InlineKeyboardMarkup([[btn("BTC"), btn("mBTC"), btn("sats")]])
MENU_FILTER = filters.Regex(r"^(Add Wallet|List|Balance|Receive|Load|Close|Send|🌐 Language)$")
NOT_MENU = ~MENU_FILTER
# ── Conversation states ───────────────────────────────────────────────────────
RESTORE_NAME, RESTORE_XPUB, RESTORE_PASSWORD = range(3)
OP_SELECT, OP_PASSWORD = range(2)
S_MENU, S_FEE_SRC, S_RATE, S_PASS, S_ADDR, S_AMT, S_BCAST = range(7)
# ── Core helpers ──────────────────────────────────────────────────────────────
def load_allowed_users() -> set[int]:
users: set[int] = set()
try:
with open(ALLOWED_USERS_FILE) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
uid = line.split(",", 1)[0].strip()
if uid.isdigit():
users.add(int(uid))
except FileNotFoundError:
logger.warning("Allowed users file not found: %s", ALLOWED_USERS_FILE)
return users
def user_wallet_path(user_id: int) -> str:
path = f"{WALLETS_BASE}/{user_id}"
os.makedirs(path, exist_ok=True)
return path
def list_user_wallets(user_id: int) -> list[str]:
return sorted(os.listdir(user_wallet_path(user_id)))
def run_electrum(*args: str) -> tuple[str, str, int]:
result = subprocess.run(
["electrum", *args], capture_output=True, text=True, timeout=30,
)
return result.stdout.strip(), result.stderr.strip(), result.returncode
async def auth_check(update: Update) -> bool:
uid = update.effective_user.id
if uid not in load_allowed_users():
await update.message.reply_text(t(uid, "not_authorized"))
return False
return True
async def reply(update: Update, text: str, **kwargs) -> None:
if update.callback_query:
await update.callback_query.edit_message_text(text, **kwargs)
else:
await update.message.reply_text(text, reply_markup=MAIN_KEYBOARD, **kwargs)
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
context.user_data.clear()
await update.message.reply_text(t(uid, "cancelled"), reply_markup=MAIN_KEYBOARD)
return ConversationHandler.END
async def cancel_for_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
context.user_data.clear()
await update.message.reply_text(t(uid, "cancel_for_button"), reply_markup=MAIN_KEYBOARD)
return ConversationHandler.END
CONV_FALLBACKS = [
CommandHandler("cancel", cancel),
MessageHandler(MENU_FILTER, cancel_for_button),
]
# ── Simple commands ───────────────────────────────────────────────────────────
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
uid = update.effective_user.id
await update.message.reply_text(t(uid, "welcome"), reply_markup=MAIN_KEYBOARD)
async def list_wallets(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
uid = update.effective_user.id
wallets = list_user_wallets(uid)
if wallets:
msg = t(uid, "your_wallets") + "\n" + "\n".join(wallets)
else:
msg = t(uid, "no_wallets")
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
uid = update.effective_user.id
if not get_loaded_wallet(uid):
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
return
unit = get_user_unit(uid)
lang = get_user_lang(uid)
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
if rc != 0:
await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
return
try:
data = json.loads(stdout)
msg = format_balance(data, unit, lang)
except (json.JSONDecodeError, InvalidOperation):
await update.message.reply_text(stdout, reply_markup=MAIN_KEYBOARD)
return
await update.message.reply_text(msg, reply_markup=unit_keyboard(unit))
async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
uid = query.from_user.id
unit = query.data.split(":", 1)[1]
set_user_unit(uid, unit)
lang = get_user_lang(uid)
loaded = get_loaded_wallet(uid)
if not loaded:
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
return
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
if rc != 0:
await query.edit_message_text(t(uid, "error", error=stderr or stdout))
return
try:
data = json.loads(stdout)
msg = format_balance(data, unit, lang)
except (json.JSONDecodeError, InvalidOperation):
msg = stdout
await query.edit_message_text(msg, reply_markup=unit_keyboard(unit))
async def receive_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
uid = update.effective_user.id
loaded = get_loaded_wallet(uid)
if not loaded:
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
return
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
stdout, stderr, rc = run_electrum("getunusedaddress", "-w", wallet_path)
msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout)
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
async def lang_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
uid = update.effective_user.id
await update.message.reply_text(t(uid, "language_select"), reply_markup=LANG_KB)
async def lang_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
uid = query.from_user.id
lang = query.data.split(":", 1)[1]
set_user_lang(uid, lang)
await query.edit_message_text(t(uid, "language_changed"))
# ── Restore conversation ──────────────────────────────────────────────────────
async def restore_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
if not await auth_check(update):
return ConversationHandler.END
uid = update.effective_user.id
await update.message.reply_text(t(uid, "add_wallet_intro"), parse_mode=ParseMode.HTML)
return RESTORE_NAME
async def restore_got_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["walletname"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.reply_text(t(uid, "enter_xpub"))
return RESTORE_XPUB
async def restore_got_pubkey(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["pubkey"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.reply_text(t(uid, "enter_password"))
return RESTORE_PASSWORD
async def restore_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
password = update.message.text.strip()
await update.message.delete()
if not validate_password(password):
await context.bot.send_message(update.effective_chat.id, t(uid, "password_invalid"))
return RESTORE_PASSWORD
context.user_data["password"] = password
walletname = context.user_data["walletname"]
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
await update.message.reply_text(t(uid, "restoring", name=walletname))
stdout, stderr, rc = run_electrum(
"restore", "--password", context.user_data["password"],
"--encrypt_file", "true", "-w", wallet_path, context.user_data["pubkey"],
)
msg = t(uid, "wallet_restored", name=walletname) if rc == 0 else t(uid, "error", error=stderr or stdout)
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
# ── Load / Close conversation ─────────────────────────────────────────────────
async def op_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
if not await auth_check(update):
return ConversationHandler.END
uid = update.effective_user.id
raw = (update.message.text or "").lstrip("/").split()[0].lower()
context.user_data["op"] = raw
wallets = list_user_wallets(uid)
if not wallets:
await update.message.reply_text(t(uid, "no_wallets"), reply_markup=MAIN_KEYBOARD)
return ConversationHandler.END
if len(wallets) == 1:
return await _wallet_selected(update, context, wallets[0])
keyboard = [[InlineKeyboardButton(w, callback_data=w)] for w in wallets]
await update.message.reply_text(t(uid, "select_wallet"), reply_markup=InlineKeyboardMarkup(keyboard))
return OP_SELECT
async def op_wallet_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.callback_query.answer()
return await _wallet_selected(update, context, update.callback_query.data)
async def _wallet_selected(
update: Update, context: ContextTypes.DEFAULT_TYPE, walletname: str
) -> int:
uid = update.effective_user.id
context.user_data["walletname"] = walletname
if context.user_data.get("op") == "load":
await reply(update, t(uid, "wallet_enter_password", name=walletname))
return OP_PASSWORD
return await _execute_op(update, context)
async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["password"] = update.message.text.strip()
await update.message.delete()
return await _execute_op(update, context)
async def _execute_op(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
op = context.user_data.get("op", "")
walletname = context.user_data.get("walletname", "")
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
cmd_map = {
"load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")),
"close": ("close_wallet", "-w", wallet_path),
}
electrum_cmd = cmd_map.get(op)
if electrum_cmd:
_, stderr, rc = run_electrum(*electrum_cmd)
if rc == 0:
if op == "load":
set_loaded_wallet(uid, walletname)
msg = t(uid, "wallet_loaded", name=walletname)
else:
set_loaded_wallet(uid, None)
msg = t(uid, "wallet_closed", name=walletname)
else:
msg = t(uid, "error", error=stderr)
else:
msg = t(uid, "unknown_op")
await reply(update, msg)
context.user_data.clear()
return ConversationHandler.END
# ── Send conversation ─────────────────────────────────────────────────────────
async def send_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
if not await auth_check(update):
return ConversationHandler.END
uid = update.effective_user.id
await update.message.reply_text(t(uid, "send_menu"), reply_markup=submenu_kb(uid))
return S_MENU
async def _show_submenu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
await context.bot.send_message(update.effective_chat.id, t(uid, "send_menu"), reply_markup=submenu_kb(uid))
return S_MENU
async def fee_choice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.callback_query.from_user.id
await update.callback_query.answer()
await update.callback_query.edit_message_text(t(uid, "fee_source"), reply_markup=fee_kb(uid))
return S_FEE_SRC
async def fee_electrum(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
query = update.callback_query
uid = query.from_user.id
await query.answer()
stdout, stderr, rc = run_electrum("getfeerate")
if rc == 0:
try:
d = json.loads(stdout)
msg = t(uid, "recommended_fee", tip=d["tooltip"], desc=d["description"])
except (json.JSONDecodeError, KeyError):
msg = stdout
else:
msg = t(uid, "error", error=stderr or stdout)
await query.edit_message_text(msg)
return await _show_submenu(update, context)
async def fee_mempool(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
query = update.callback_query
await query.answer()
uid = query.from_user.id
try:
res = subprocess.run(
["curl", "-sSL", "https://mempool.space/api/v1/fees/recommended"],
capture_output=True, text=True, timeout=10,
)
d = json.loads(res.stdout)
msg = t(uid, "fee_mempool_msg",
fastest=d["fastestFee"], half_hour=d["halfHourFee"],
hour=d["hourFee"], economy=d["economyFee"], minimum=d["minimumFee"])
except Exception as e:
msg = t(uid, "error", error=str(e))
await query.edit_message_text(msg)
return await _show_submenu(update, context)
async def send_ask_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.callback_query.from_user.id
await update.callback_query.answer()
if not get_loaded_wallet(uid):
await update.callback_query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
return ConversationHandler.END
await update.callback_query.edit_message_text(t(uid, "enter_feerate"))
return S_RATE
async def send_got_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["feerate"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.reply_text(t(uid, "enter_password"))
return S_PASS
async def send_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["password"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.delete()
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_addr"))
return S_ADDR
async def send_got_address(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["address"] = update.message.text.strip()
uid = update.effective_user.id
unit = get_user_unit(uid)
await update.message.reply_text(t(uid, "enter_amount", unit=unit), reply_markup=amount_kb(uid))
return S_AMT
async def send_max_cb(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.callback_query.answer()
await update.callback_query.edit_message_reply_markup(reply_markup=None)
return await _build_tx(update, context, "!")
async def send_got_amount(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
unit = get_user_unit(uid)
amount_raw = update.message.text.strip()
try:
btc_amount = unit_to_btc(amount_raw, unit)
except ValueError as e:
await update.message.reply_text(t(uid, "invalid_amount", error=str(e)))
return S_AMT
return await _build_tx(update, context, btc_amount)
async def _build_tx(update: Update, context: ContextTypes.DEFAULT_TYPE, btc_amount: str) -> int:
uid = update.effective_user.id
notify = update.callback_query.message if update.callback_query else update.message
await notify.reply_text(t(uid, "creating_tx"))
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
stdout, stderr, rc = run_electrum(
"payto",
"-w", wallet_path,
"--feerate", context.user_data["feerate"],
"--rbf", "true",
"--password", context.user_data["password"],
context.user_data["address"],
btc_amount,
)
if rc != 0:
await notify.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
psbt = stdout.strip()
await notify.reply_text(
f"{t(uid, 'unsigned_tx_header')}\n\n<pre>{psbt}</pre>",
parse_mode=ParseMode.HTML,
)
file_obj = io.BytesIO(psbt.encode())
await context.bot.send_document(
chat_id=update.effective_chat.id,
document=file_obj,
filename="unsigned_tx.txt",
caption="Unsigned transaction",
)
await notify.reply_text(t(uid, "signing_instructions"), reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
async def broadcast_ask(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.callback_query.from_user.id
await update.callback_query.answer()
await update.callback_query.edit_message_text(t(uid, "enter_signed_tx"))
return S_BCAST
async def broadcast_got_tx(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
stdout, stderr, rc = run_electrum("broadcast", update.message.text.strip())
if rc == 0:
await update.message.reply_text(
f"{t(uid, 'broadcast_success')}\n<code>{stdout}</code>",
parse_mode=ParseMode.HTML,
reply_markup=MAIN_KEYBOARD,
)
else:
await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
return ConversationHandler.END
# ── App wiring ────────────────────────────────────────────────────────────────
def main() -> None:
app = Application.builder().token(TOKEN).build()
for cmd in ("start", "help"):
app.add_handler(CommandHandler(cmd, start))
app.add_handler(CommandHandler("list", list_wallets))
app.add_handler(CommandHandler("balance", balance_cmd))
app.add_handler(CommandHandler("receive", receive_cmd))
app.add_handler(MessageHandler(filters.Regex(r"^List$"), list_wallets))
app.add_handler(MessageHandler(filters.Regex(r"^Balance$"), balance_cmd))
app.add_handler(MessageHandler(filters.Regex(r"^Receive$"), receive_cmd))
app.add_handler(MessageHandler(filters.Regex(r"^🌐 Language$"), lang_menu))
# Global inline callbacks (registered before ConversationHandlers — patterns are unique)
app.add_handler(CallbackQueryHandler(unit_switch, pattern=r"^unit:"))
app.add_handler(CallbackQueryHandler(lang_switch, pattern=r"^lang:"))
app.add_handler(ConversationHandler(
entry_points=[
CommandHandler("restore", restore_start),
MessageHandler(filters.Regex(r"^Add Wallet$"), restore_start),
],
states={
RESTORE_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_name)],
RESTORE_XPUB: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_pubkey)],
RESTORE_PASSWORD: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, restore_got_password)],
},
fallbacks=CONV_FALLBACKS,
))
app.add_handler(ConversationHandler(
entry_points=[
CommandHandler("load", op_start),
CommandHandler("close", op_start),
MessageHandler(filters.Regex(r"^(Load|Close)$"), op_start),
],
states={
OP_SELECT: [CallbackQueryHandler(op_wallet_selected)],
OP_PASSWORD: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, op_got_password)],
},
fallbacks=CONV_FALLBACKS,
))
app.add_handler(ConversationHandler(
entry_points=[
CommandHandler("send", send_menu),
MessageHandler(filters.Regex(r"^Send$"), send_menu),
],
states={
S_MENU: [
CallbackQueryHandler(fee_choice, pattern="^check_fee$"),
CallbackQueryHandler(send_ask_feerate, pattern="^do_send$"),
CallbackQueryHandler(broadcast_ask, pattern="^do_broadcast$"),
],
S_FEE_SRC: [
CallbackQueryHandler(fee_electrum, pattern="^fee_electrum$"),
CallbackQueryHandler(fee_mempool, pattern="^fee_mempool$"),
],
S_RATE: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_feerate)],
S_PASS: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_password)],
S_ADDR: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_address)],
S_AMT: [
CallbackQueryHandler(send_max_cb, pattern="^send_max$"),
MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, send_got_amount),
],
S_BCAST: [MessageHandler(filters.TEXT & ~filters.COMMAND & NOT_MENU, broadcast_got_tx)],
},
fallbacks=CONV_FALLBACKS,
))
logger.info("Bot starting...")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()