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,5 @@
|
|||||||
|
# Electrum version to install.
|
||||||
|
ELECTRUM_VERSION=4.7.2
|
||||||
|
|
||||||
|
# Telegram bot token from @BotFather
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
+34
@@ -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"]
|
||||||
@@ -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
@@ -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` بررسی کنید.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Allowed Telegram user IDs
|
||||||
|
# Format: userid, comment
|
||||||
|
# One user per line. Lines starting with # are ignored.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# 123456789, Alice (admin)
|
||||||
|
# 987654321, Bob
|
||||||
|
69027304, Me (Goyban)
|
||||||
|
85794988, Momo
|
||||||
|
8031636275, TheBaaaaan
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
python-telegram-bot==20.7
|
||||||
Reference in New Issue
Block a user