Initial commit: Electrum Telegram wallet bot with Tor support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user