ab537a8f73
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
920 lines
39 KiB
Python
920 lines
39 KiB
Python
#!/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()
|