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
+5
View File
@@ -0,0 +1,5 @@
# Electrum version to install.
ELECTRUM_VERSION=4.7.2
# Telegram bot token from @BotFather
TELEGRAM_BOT_TOKEN=your_bot_token_here
+17
View File
@@ -0,0 +1,17 @@
# Electrum version to install.
ELECTRUM_VERSION=4.7.2
# Telegram bot token from @BotFather
TELEGRAM_BOT_TOKEN=your_bot_token_here
# ── Tor / server options ──────────────────────────────────────────────────────
# Option A — connect to your own Electrum server over Tor.
# Set this to your server's .onion address in host:port:protocol format.
# Protocol: t = TCP (unencrypted, Tor layer handles it), s = SSL.
# Example:
# ELECTRUM_SERVER=mypersonalnode123.onion:50001:t
# Option B — leave ELECTRUM_SERVER unset to use a public .onion server.
# The default is electrums3lojbuj.onion:50001:t, but you can override it:
# ELECTRUM_ONION_SERVER=electrums3lojbuj.onion:50001:t
+3
View File
@@ -0,0 +1,3 @@
.env
*.pyc
__pycache__/
+34
View File
@@ -0,0 +1,34 @@
FROM debian:bookworm-slim
ARG VERSION=4.7.2
RUN apt-get update && apt-get install -y --no-install-recommends \
wget curl \
gnupg \
ca-certificates \
python3 \
python3-venv \
python3-cryptography \
libsecp256k1-dev \
build-essential \
pkg-config \
autoconf \
automake \
libtool \
procps \
&& rm -rf /var/lib/apt/lists/*
COPY install.sh /usr/local/bin/install-electrum.sh
RUN chmod +x /usr/local/bin/install-electrum.sh \
&& /usr/local/bin/install-electrum.sh "${VERSION}"
COPY requirements.txt /app/requirements.txt
RUN /opt/electrum/bin/pip install --quiet -r /app/requirements.txt
COPY bot.py /app/bot.py
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /root
ENTRYPOINT ["/app/entrypoint.sh"]
+35
View File
@@ -0,0 +1,35 @@
FROM debian:bookworm-slim
ARG VERSION=4.7.2
RUN apt-get update && apt-get install -y --no-install-recommends \
wget curl \
gnupg \
ca-certificates \
python3 \
python3-venv \
python3-cryptography \
libsecp256k1-dev \
build-essential \
pkg-config \
autoconf \
automake \
libtool \
procps \
tor \
&& rm -rf /var/lib/apt/lists/*
COPY install.sh /usr/local/bin/install-electrum.sh
RUN chmod +x /usr/local/bin/install-electrum.sh \
&& /usr/local/bin/install-electrum.sh "${VERSION}"
COPY requirements.txt /app/requirements.txt
RUN /opt/electrum/bin/pip install --quiet -r /app/requirements.txt
COPY bot.py /app/bot.py
COPY entrypoint.tor.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /root
ENTRYPOINT ["/app/entrypoint.sh"]
+243
View File
@@ -0,0 +1,243 @@
# ربات کیف‌پول آنلاین
یک ربات تلگرام خودمیزبان برای مدیریت کیف‌پول‌های بیت‌کوین با استفاده از Electrum. طراحی‌شده برای کیف‌پول‌های فقط-تماشا (xpub) با پشتیبانی از امضای آفلاین (air-gapped).
## قابلیت‌ها
- کیف‌پول جداگانه برای هر کاربر
- بازیابی کیف‌پول فقط-تماشا از xpub
- نمایش موجودی (تأییدشده / تأییدنشده)
- دریافت آدرس
- ساخت تراکنش امضانشده (PSBT) با انتخاب کارمزد
- تغییر واحد: BTC / mBTC / ساتوشی
- پشتیبانی از زبان فارسی و انگلیسی
- کیبورد ثابت در پایین چت
- پشتیبانی اختیاری از Tor (اتصال از طریق .onion به سرور عمومی یا نود شخصی)
---
## پیش‌نیازها
- یک سرور لینوکسی با Docker و Docker Compose نصب‌شده
- توکن ربات تلگرام (از [@BotFather](https://t.me/BotFather))
- شناسه کاربری تلگرام شما (از [@userinfobot](https://t.me/userinfobot))
---
## راه‌اندازی سرور (گام‌به‌گام)
### ۱. کلون کردن مخزن
```bash
git clone https://git.goyban.com/goyban/telegtrum_bot.git
cd telegtrum_bot
```
### ۲. ساخت فایل `.env`
```bash
cp .env.example .env
nano .env
```
مقادیر را پر کنید:
```
ELECTRUM_VERSION=4.7.2
TELEGRAM_BOT_TOKEN=توکن_ربات_شما
```
### ۳. افزودن شناسه کاربری تلگرام به لیست کاربران مجاز
```bash
nano allowed_users.txt
```
یک خط به این فرمت اضافه کنید:
```
123456789, علی
```
شناسه کاربری خود را می‌توانید با ارسال پیام به [@userinfobot](https://t.me/userinfobot) در تلگرام پیدا کنید.
### ۴. ساخت و راه‌اندازی کانتینر
```bash
docker compose up -d --build
```
این دستور:
- ایمیج Docker با Electrum نصب‌شده را می‌سازد
- دیمن Electrum را راه‌اندازی می‌کند
- ربات تلگرام را اجرا می‌کند
### ۵. بررسی لاگ‌ها
```bash
docker compose logs -f
```
باید پیام `Electrum daemon started. Launching bot...` را ببینید.
---
## حالت Tor
حالت Tor، Electrum را از طریق شبکه Tor اجرا می‌کند. از یک فایل Docker Compose و Dockerfile جداگانه استفاده می‌کند تا راه‌اندازی استاندارد تحت تأثیر قرار نگیرد.
### راه‌اندازی
```bash
cp .env.tor.example .env
nano .env
```
`TELEGRAM_BOT_TOKEN` را پر کنید و یکی از حالت‌های اتصال را انتخاب کنید:
**گزینه الف — نود Electrum شخصی از طریق Tor:**
```
ELECTRUM_SERVER=yournode123.onion:50001:t
```
**گزینه ب — سرور عمومی .onion (پیش‌فرض، نیاز به تنظیم اضافه ندارد):**
`ELECTRUM_SERVER` را خالی بگذارید. ربات به `electrums3lojbuj.onion:50001:t` وصل می‌شود. برای تغییر از `ELECTRUM_ONION_SERVER` استفاده کنید.
### راه‌اندازی
```bash
docker compose -f docker-compose.tor.yml up -d --build
```
### نکات
- Tor داخل همان کانتینر اجرا می‌شود — نیازی به سرویس جداگانه نیست.
- کانتینر صبر می‌کند تا SOCKS proxy آماده شود، سپس Electrum را راه‌اندازی می‌کند.
- داده‌های کیف‌پول در یک volume جداگانه (`electrum-tor-data`) ذخیره می‌شود.
- حرف `t` در آدرس سرور به معنای TCP است — رمزنگاری توسط لایه Tor انجام می‌شود نه SSL.
---
## به‌روزرسانی ربات
فایل `bot.py` به‌صورت volume سوار شده، پس بدون نیاز به rebuild می‌توانید آن را تغییر دهید:
```bash
# ویرایش bot.py، سپس:
docker compose restart
# یا برای حالت Tor:
docker compose -f docker-compose.tor.yml restart
```
برای به‌روزرسانی Electrum یا پکیج‌های سیستم:
```bash
docker compose up -d --build
# یا برای حالت Tor:
docker compose -f docker-compose.tor.yml up -d --build
```
---
## افزودن یا حذف کاربران مجاز
فایل `allowed_users.txt` را ویرایش کرده و ریستارت کنید:
```bash
nano allowed_users.txt
docker compose restart
```
---
## ذخیره‌سازی داده‌ها
داده‌های کیف‌پول در یک volume نام‌دار Docker در مسیر `/root/.electrum` داخل کانتینر ذخیره می‌شود و در ریستارت‌ها و rebuild‌ها باقی می‌ماند.
| حالت | نام volume |
|---|---|
| استاندارد | `electrum-data` |
| Tor | `electrum-tor-data` |
برای پشتیبان‌گیری (حالت استاندارد):
```bash
docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \
tar czf /backup/electrum-backup.tar.gz -C /data .
```
---
## راهنمای استفاده از ربات
### شروع کار
ربات را در تلگرام باز کنید و هر پیامی ارسال کنید. یک کیبورد ثابت در پایین چت با تمام دکمه‌ها نمایش داده می‌شود.
### افزودن کیف‌پول (Add Wallet)
وارد کردن کیف‌پول فقط-تماشا از xpub:
1. روی **Add Wallet** بزنید
2. نام کیف‌پول را وارد کنید
3. xpub خود را پیست کنید
4. رمز عبور تعیین کنید (حداقل ۸ کاراکتر، شامل حرف بزرگ، کوچک و عدد)
### لیست (List)
نمایش تمام کیف‌پول‌های شما.
### بارگذاری (Load)
بارگذاری یک کیف‌پول برای فعال‌سازی:
1. روی **Load** بزنید
2. کیف‌پول را انتخاب کنید (اگر فقط یک کیف‌پول دارید، خودکار انتخاب می‌شود)
3. رمز عبور کیف‌پول را وارد کنید
### موجودی (Balance)
نمایش موجودی تأییدشده و تأییدنشده کیف‌پول فعال.
با دکمه‌های inline زیر موجودی می‌توانید بین **BTC**، **mBTC** و **ساتوشی** تغییر دهید.
### دریافت (Receive)
نمایش آدرس دریافت جدید برای کیف‌پول فعال.
### ارسال (Send)
ساخت تراکنش امضانشده (PSBT) برای امضای آفلاین:
1. روی **Send** بزنید
2. منبع کارمزد را انتخاب کنید: **Electrum** (پیشنهادی) یا **mempool.space** (زنده)
3. نرخ کارمزد را به صورت sat/vB وارد کنید
4. رمز عبور کیف‌پول را وارد کنید
5. آدرس گیرنده را وارد کنید
6. مقدار را وارد کنید (بر اساس واحد انتخابی)
7. تراکنش امضانشده را به‌صورت کد و فایل `.txt` دریافت کنید
8. آن را با کیف‌پول آفلاین خود امضا کنید، سپس از **Broadcast** استفاده کنید
### بستن (Close)
کیف‌پول فعال را از دیمن Electrum خارج می‌کند.
### زبان
روی **🌐 Language** بزنید تا بین فارسی و انگلیسی تغییر دهید.
---
## عیب‌یابی
**ربات پاسخ نمی‌دهد:** لاگ‌ها را با `docker compose logs -f` بررسی کنید. مطمئن شوید توکن ربات درست است و کانتینر در حال اجراست.
**"شما مجاز نیستید":** شناسه کاربری تلگرام شما در `allowed_users.txt` نیست. آن را اضافه کرده و ریستارت کنید.
**خطای کیف‌پول بارگذاری‌نشده:** قبل از بررسی موجودی، دریافت یا ارسال، از **Load** استفاده کنید.
**دیمن شروع نمی‌شود:** کانتینر lockfile را هنگام راه‌اندازی خودکار حذف می‌کند. اگر مشکل ادامه داشت، `docker compose restart` را امتحان کنید.
**حالت Tor — Electrum وصل نمی‌شود:** bootstrap اولیه Tor تا ۳۰ ثانیه طول می‌کشد. لاگ‌ها را با `docker compose -f docker-compose.tor.yml logs -f` بررسی کنید.
+243
View File
@@ -0,0 +1,243 @@
# Online Wallet Bot
A self-hosted Telegram bot for managing Bitcoin wallets using Electrum. Designed for watch-only (xpub) wallets with air-gapped signing support.
## Features
- Per-user isolated wallets
- Watch-only wallet restore from xpub
- Balance display (Confirmed / Unconfirmed)
- Receive address generation
- Unsigned transaction (PSBT) creation with fee options
- Unit switching: BTC / mBTC / sats
- Persian (Farsi) and English language support
- Persistent bottom keyboard
- Optional Tor support (connect via .onion to a public server or your own node)
---
## Prerequisites
- A Linux server with Docker and Docker Compose installed
- A Telegram bot token (from [@BotFather](https://t.me/BotFather))
- Your Telegram user ID (from [@userinfobot](https://t.me/userinfobot))
---
## Server Setup (Step by Step)
### 1. Clone the repository
```bash
git clone https://git.goyban.com/goyban/telegtrum_bot.git
cd telegtrum_bot
```
### 2. Create your `.env` file
```bash
cp .env.example .env
nano .env
```
Fill in:
```
ELECTRUM_VERSION=4.7.2
TELEGRAM_BOT_TOKEN=your_bot_token_here
```
### 3. Add your Telegram user ID to the allowed users list
```bash
nano allowed_users.txt
```
Add a line in the format `userid, Name`:
```
123456789, Alice
```
You can find your user ID by messaging [@userinfobot](https://t.me/userinfobot) on Telegram.
### 4. Build and start the container
```bash
docker compose up -d --build
```
This will:
- Build the Docker image with Electrum installed
- Start the Electrum daemon
- Launch the Telegram bot
### 5. Check logs
```bash
docker compose logs -f
```
You should see `Electrum daemon started. Launching bot...` followed by the bot starting up.
---
## Tor Mode
Tor mode runs Electrum through the Tor network. It uses a separate Docker Compose file and Dockerfile so the standard setup is not affected.
### Setup
```bash
cp .env.tor.example .env
nano .env
```
Fill in `TELEGRAM_BOT_TOKEN` and choose a connection mode:
**Option A — your own Electrum server over Tor:**
```
ELECTRUM_SERVER=yournode123.onion:50001:t
```
**Option B — public .onion server (default, no extra config needed):**
Leave `ELECTRUM_SERVER` unset. The bot will connect to `electrums3lojbuj.onion:50001:t` by default. Override with `ELECTRUM_ONION_SERVER` if needed.
### Start
```bash
docker compose -f docker-compose.tor.yml up -d --build
```
### Notes
- Tor runs inside the same container — no sidecar needed.
- The container waits for the Tor SOCKS proxy to be ready before starting Electrum.
- Wallet data is stored in a separate volume (`electrum-tor-data`) so it does not mix with the standard setup.
- `t` in the server string means TCP — encryption is handled by the Tor layer, not SSL.
---
## Updating the Bot
The `bot.py` file is mounted as a volume, so you can update it without rebuilding the image:
```bash
# Edit bot.py, then:
docker compose restart
# or for Tor mode:
docker compose -f docker-compose.tor.yml restart
```
To update Electrum itself or system packages:
```bash
docker compose up -d --build
# or for Tor mode:
docker compose -f docker-compose.tor.yml up -d --build
```
---
## Adding or Removing Allowed Users
Edit `allowed_users.txt` and restart:
```bash
nano allowed_users.txt
docker compose restart
```
---
## Data Persistence
Wallet data is stored in a named Docker volume at `/root/.electrum` inside the container. This persists across restarts and rebuilds.
| Mode | Volume name |
|---|---|
| Standard | `electrum-data` |
| Tor | `electrum-tor-data` |
To back up wallet data (standard mode):
```bash
docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \
tar czf /backup/electrum-backup.tar.gz -C /data .
```
---
## Bot Usage Guide
### Starting the bot
Open the bot in Telegram and send any message. You'll see a persistent keyboard at the bottom with all available actions.
### Add Wallet
Import a watch-only wallet from an xpub (extended public key):
1. Press **Add Wallet**
2. Enter a wallet name
3. Paste your xpub
4. Set a password (min 8 chars, must include upper, lower, and digit)
### List
Shows all your wallets.
### Load
Load a wallet to make it active:
1. Press **Load**
2. Select a wallet (auto-selects if you only have one)
3. Enter the wallet password
### Balance
Shows the confirmed and unconfirmed balance of the loaded wallet.
Use the inline buttons below the balance to switch between **BTC**, **mBTC**, and **sats**.
### Receive
Shows a fresh receiving address for the loaded wallet.
### Send
Creates an unsigned transaction (PSBT) for air-gapped signing:
1. Press **Send**
2. Choose a fee source: **Electrum** (recommended) or **mempool.space** (live)
3. Enter fee rate in sat/vB
4. Enter the wallet password
5. Enter the recipient address
6. Enter the amount (in your selected unit)
7. Receive the unsigned TX as both a code block and a `.txt` file for offline signing
8. Sign it with your offline wallet, then use **Broadcast**
### Close
Unloads the active wallet from the Electrum daemon.
### Language
Press **🌐 Language** to switch between English and Persian (Farsi).
---
## Troubleshooting
**Bot not responding:** Check logs with `docker compose logs -f`. Make sure your bot token is correct and the container is running.
**"You are not authorized":** Your Telegram user ID is not in `allowed_users.txt`. Add it and restart.
**Wallet not loaded error:** Use **Load** before checking balance, receiving, or sending.
**Daemon not starting:** The container removes the lockfile on startup automatically. If issues persist, try `docker compose restart`.
**Tor mode — Electrum not connecting:** Tor bootstrap can take up to ~30 seconds on first run. Check logs with `docker compose -f docker-compose.tor.yml logs -f`.
+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()
+25
View File
@@ -0,0 +1,25 @@
services:
electrum:
build:
context: .
dockerfile: Dockerfile.tor
args:
VERSION: ${ELECTRUM_VERSION:-4.7.2}
container_name: electrum-tor
env_file: .env
environment:
ALLOWED_USERS_FILE: /app/allowed_users.txt
volumes:
- electrum-tor-data:/root/.electrum
- ./allowed_users.txt:/app/allowed_users.txt:ro
- ./bot.py:/app/bot.py:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "electrum", "getinfo"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
electrum-tor-data:
+24
View File
@@ -0,0 +1,24 @@
services:
electrum:
build:
context: .
args:
VERSION: ${ELECTRUM_VERSION:-4.7.2}
container_name: electrum
env_file: .env
environment:
ALLOWED_USERS_FILE: /app/allowed_users.txt
volumes:
- electrum-data:/root/.electrum
- ./allowed_users.txt:/app/allowed_users.txt:ro
- ./bot.py:/app/bot.py:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "electrum", "getinfo"]
interval: 30s
timeout: 10s
retries: 5
start_period: 15s
volumes:
electrum-data:
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
mkdir -p /root/.electrum/wallets
echo "Starting Electrum daemon..."
rm -f /root/.electrum/daemon
electrum daemon -d
echo "Electrum daemon started. Launching bot..."
exec /opt/electrum/bin/python3 /app/bot.py
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -e
mkdir -p /root/.electrum/wallets
echo "Starting Tor..."
tor --RunAsDaemon 1 --SocksPort 9050 --DataDirectory /var/lib/tor
echo "Waiting for Tor SOCKS proxy on 127.0.0.1:9050..."
until echo > /dev/tcp/127.0.0.1/9050 2>/dev/null; do
sleep 1
done
echo "Tor is ready."
echo "Starting Electrum daemon..."
rm -f /root/.electrum/daemon
PROXY="socks5:localhost:9050"
if [ -n "${ELECTRUM_SERVER:-}" ]; then
# Pin to a single user-specified server (own node)
electrum -1 -s "${ELECTRUM_SERVER}" -p "${PROXY}" daemon -d
else
# Route through Tor using a public .onion server
electrum -s "${ELECTRUM_ONION_SERVER:-electrums3lojbuj.onion:50001:t}" -p "${PROXY}" daemon -d
fi
echo "Electrum daemon started. Launching bot..."
exec /opt/electrum/bin/python3 /app/bot.py
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
ELECTRUM_VERSION="${1:-4.7.2}"
echo ">>> Installing Electrum ${ELECTRUM_VERSION}"
WORKDIR="/tmp/electrum"
mkdir -p "$WORKDIR"
cd "$WORKDIR"
BASE_URL="https://download.electrum.org/${ELECTRUM_VERSION}"
TARBALL="Electrum-${ELECTRUM_VERSION}.tar.gz"
SIG_FILE="${TARBALL}.asc"
FINGERPRINT_THOMASV='6694 D8DE 7BE8 EE56 31BE D950 2BD5 824B 7F94 70E6'
FINGERPRINT_SOMBERNIGHT='0EED CFD5 CAFB 4590 6734 9B23 CA9E EEC4 3DF9 11DC'
FINGERPRINT_EMZY='9EDA FF80 E080 6596 04F4 A76B 2EBB 056F D847 F8A7'
PUB_THOMASV='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/ThomasV.asc'
PUB_SOMBERNIGHT='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/sombernight_releasekey.asc'
PUB_EMZY='https://raw.githubusercontent.com/spesmilo/electrum/master/pubkeys/Emzy.asc'
echo ">>> Importing signing keys..."
for pubkey_url in "$PUB_THOMASV" "$PUB_SOMBERNIGHT" "$PUB_EMZY"; do
wget -q "$pubkey_url"
done
for key_file in *.asc; do
gpg --import "$key_file"
rm -f "$key_file"
done
echo ">>> Downloading Electrum ${ELECTRUM_VERSION}..."
wget -q "${BASE_URL}/${TARBALL}"
wget -q "${BASE_URL}/${SIG_FILE}"
echo ">>> Verifying GPG signature..."
VERIFY_OUTPUT=$(gpg --verify "$SIG_FILE" "$TARBALL" 2>&1 || true)
echo "$VERIFY_OUTPUT"
echo "$VERIFY_OUTPUT" | grep -q "Good signature" \
|| { echo "ERROR: GPG verification failed — no good signature found" >&2; exit 1; }
MATCHED=0
for fp in "$FINGERPRINT_THOMASV" "$FINGERPRINT_SOMBERNIGHT" "$FINGERPRINT_EMZY"; do
if echo "$VERIFY_OUTPUT" | grep -qF "$fp"; then
echo ">>> Fingerprint matched: ${fp}"
MATCHED=1
break
fi
done
[ "$MATCHED" -eq 1 ] \
|| { echo "ERROR: no trusted fingerprint found in verify output" >&2; exit 1; }
echo ">>> Creating venv and installing Electrum..."
python3 -m venv --system-site-packages /opt/electrum
/opt/electrum/bin/pip install --quiet --use-pep517 "${WORKDIR}/${TARBALL}"
ln -sf /opt/electrum/bin/electrum /usr/bin/electrum
rm -rf "$WORKDIR"
echo ">>> Done: Electrum ${ELECTRUM_VERSION} installed"
echo ">>> Try: electrum --help"
+1
View File
@@ -0,0 +1 @@
python-telegram-bot==20.7