Security fixes, ready-to-deploy image, and rewritten docs
Audit fixes: - Wallet names were used unvalidated as file paths, so a name like "../<other-id>/x" reached another user's wallet directory or overwrote bot_settings.json. Names are now validated and every path is resolved and confirmed to sit inside the caller's own directory, which also blocks a planted symlink from escaping. - Tor mode leaked: the mempool.space fee check opened a direct clear-net connection, tying the server's IP to its Bitcoin activity. It now goes through Tor's SOCKS proxy to mempool's onion service, DNS included. - The xpub message was left in the chat forever. It reveals the wallet's whole address and balance history, so it is now deleted like the password is. - Inline buttons stay live on old messages, so a user removed from the allow-list could keep using them. Callbacks re-check authorisation, and auth_check now works for callback queries instead of raising on a null message. - Wallet names echoed back in callback_data are confirmed against the user's real wallet list. - Settings are written via temp file + atomic rename; a crash mid-write used to truncate the file and lose every user's wallet selection. - Replies were sent against messages that had just been deleted; deletion is now best-effort and cannot abort the flow. Deployment: - Allow-list can come from ALLOWED_USERS instead of a bind-mounted file, so a deployment needs no files on disk. Both sources merge when both are present. - A missing bind-mounted allowed_users.txt made Docker create a directory in its place and silently lock everyone out; the entrypoint now detects that and stops with an explanation. - Standard mode honours ELECTRUM_SERVER, which previously only worked in Tor mode despite being documented. - Added VERSION, push.sh, docker-compose.deploy.yml and .env.deploy.example for a two-file deploy off the published image. Docs rewritten in English and Persian around the name Telegtrum, leading with the security model and what it does not protect against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# ── Telegtrum deployment config ──────────────────────────────────────────
|
||||||
|
# Copy to .env and fill in. Never commit the real file.
|
||||||
|
|
||||||
|
# Telegram bot token from @BotFather
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# Who may use the bot. Comma-separated Telegram user IDs.
|
||||||
|
# Find yours by messaging @userinfobot on Telegram.
|
||||||
|
# Anyone not listed here is refused — leaving it empty locks everyone out.
|
||||||
|
ALLOWED_USERS=123456789
|
||||||
|
|
||||||
|
# ── Optional ─────────────────────────────────────────────────────────────
|
||||||
|
# Pin Electrum to your own server instead of the public random peers:
|
||||||
|
# ELECTRUM_SERVER=192.168.1.100:50001:t
|
||||||
+170
-187
@@ -1,243 +1,226 @@
|
|||||||
# ربات کیفپول آنلاین
|
# تلهگتروم (Telegtrum)
|
||||||
|
|
||||||
یک ربات تلگرام خودمیزبان برای مدیریت کیفپولهای بیتکوین با استفاده از Electrum. طراحیشده برای کیفپولهای فقط-تماشا (xpub) با پشتیبانی از امضای آفلاین (air-gapped).
|
**تلگرام + الکترום** — یک ربات تلگرام خودمیزبان برای مدیریت کیفپولهای بیتکوین با Electrum.
|
||||||
|
|
||||||
## قابلیتها
|
تلهگتروم برای **کیفپولهای فقط-تماشا (xpub) با امضای آفلاین (air-gapped)** ساخته شده است. کلیدهای خصوصی شما هرگز روی سرور قرار نمیگیرند. ربات میتواند موجودی را ببیند، آدرس دریافت بسازد و تراکنش **امضانشده** بسازد؛ امضا فقط روی دستگاه آفلاین شما انجام میشود.
|
||||||
|
|
||||||
- کیفپول جداگانه برای هر کاربر
|
---
|
||||||
- بازیابی کیفپول فقط-تماشا از xpub
|
|
||||||
- نمایش موجودی (تأییدشده / تأییدنشده)
|
## مدل امنیتی — این بخش را حتماً بخوانید
|
||||||
- دریافت آدرس
|
|
||||||
- ساخت تراکنش امضانشده (PSBT) با انتخاب کارمزد
|
| | |
|
||||||
- تغییر واحد: BTC / mBTC / ساتوشی
|
|---|---|
|
||||||
- پشتیبانی از زبان فارسی و انگلیسی
|
| **سرور هیچ کلید خصوصی ندارد** | شما فقط **xpub** وارد میکنید. قابلیت تماشا دارد، نه خرج کردن. |
|
||||||
- کیبورد ثابت در پایین چت
|
| **خرج کردن نیازمند دستگاه آفلاین است** | ربات تراکنش امضانشده میسازد؛ شما آن را جای دیگر امضا و برای انتشار برمیگردانید. |
|
||||||
- پشتیبانی اختیاری از Tor (اتصال از طریق .onion به سرور عمومی یا نود شخصی)
|
| **دسترسی بهصورت پیشفرض بسته است** | فقط شناسههای تلگرامی که شما تعیین کنید اجازه دارند. فهرست خالی یعنی هیچکس اجازه ندارد. |
|
||||||
|
| **اطلاعات حساس از چت پاک میشوند** | رمز کیفپول و xpub بلافاصله پس از خواندهشدن حذف میشوند. |
|
||||||
|
| **کیفپول هر کاربر جداست** | هر کاربر پوشهٔ مخصوص خود را دارد و نامها اعتبارسنجی میشوند تا کسی به فایلهای دیگری دسترسی پیدا نکند. |
|
||||||
|
|
||||||
|
**در برابر چه چیزهایی محافظت نمیکند.** هر کسی که به حساب تلگرام شما دسترسی داشته باشد میتواند موجودی و آدرسها را ببیند و تراکنش بسازد (اما نمیتواند امضا کند). هر کسی که دسترسی root روی سرور داشته باشد میتواند فایلهای کیفپول را بخواند و در لحظهٔ اجرای دستور Electrum رمز کیفپول را در فهرست پردازهها ببیند — پس سرور را قابلاعتماد نگه دارید و فهرست مجاز را کوتاه. xpub شما یک راز حریم خصوصی است: هر کس آن را داشته باشد تمام تاریخچهٔ تراکنشهای شما را برای همیشه میبیند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## راهاندازی سریع (پیشنهادی)
|
||||||
|
|
||||||
|
بدون کلون کردن مخزن و بدون build. فقط دو فایل و یک دستور.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir telegtrum && cd telegtrum
|
||||||
|
|
||||||
|
curl -O https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/docker-compose.deploy.yml
|
||||||
|
curl -o .env https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/.env.deploy.example
|
||||||
|
|
||||||
|
nano .env # توکن ربات و شناسه کاربری تلگرام خود را وارد کنید
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
docker compose -f docker-compose.deploy.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
فایل `.env` به دو مقدار نیاز دارد:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC... # از @BotFather
|
||||||
|
ALLOWED_USERS=123456789 # از @userinfobot، برای چند نفر با کاما جدا کنید
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `ALLOWED_USERS` تنظیم نشده باشد، compose اجرا نمیشود — بهجای اینکه رباتی بالا بیاید که هیچکس نتواند از آن استفاده کند.
|
||||||
|
|
||||||
|
باید پیام `Electrum daemon started. Launching bot...` را ببینید. سپس در تلگرام به ربات پیام دهید و **Add Wallet** را بزنید.
|
||||||
|
|
||||||
|
### بهروزرسانی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.deploy.yml pull
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
اطلاعات کیفپول در ولوم `telegtrum-data` باقی میماند و با بهروزرسانی از بین نمیرود.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## پیشنیازها
|
## پیشنیازها
|
||||||
|
|
||||||
- یک سرور لینوکسی با Docker و Docker Compose نصبشده
|
- یک سرور لینوکسی با Docker و Docker Compose
|
||||||
- توکن ربات تلگرام (از [@BotFather](https://t.me/BotFather))
|
- توکن ربات تلگرام — [@BotFather](https://t.me/BotFather)
|
||||||
- شناسه کاربری تلگرام شما (از [@userinfobot](https://t.me/userinfobot))
|
- شناسه کاربری تلگرام شما — [@userinfobot](https://t.me/userinfobot)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## راهاندازی سرور (گامبهگام)
|
## ساخت از روی سورس
|
||||||
|
|
||||||
### ۱. کلون کردن مخزن
|
فقط اگر میخواهید ربات را تغییر دهید.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.goyban.com/goyban/telegtrum_bot.git
|
git clone https://git.goyban.com/goyban/telegtrum_bot.git
|
||||||
cd telegtrum_bot
|
cd telegtrum_bot
|
||||||
```
|
cp .env.example .env && nano .env
|
||||||
|
|
||||||
### ۲. ساخت فایل `.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 compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
این دستور:
|
در این حالت فایل `bot.py` بهصورت read-only مانت میشود، پس میتوانید آن را ویرایش کنید و فقط `docker compose restart` بزنید.
|
||||||
- ایمیج Docker با Electrum نصبشده را میسازد
|
|
||||||
- دیمن Electrum را راهاندازی میکند
|
|
||||||
- ربات تلگرام را اجرا میکند
|
|
||||||
|
|
||||||
### ۵. بررسی لاگها
|
### فهرست کاربران مجاز: فایل یا متغیر محیطی
|
||||||
|
|
||||||
```bash
|
هر دو کار میکنند؛ اگر هر دو را استفاده کنید، با هم ادغام میشوند.
|
||||||
docker compose logs -f
|
|
||||||
|
**متغیر محیطی** (سادهترین راه — نیازی به مانت فایل نیست):
|
||||||
|
```dotenv
|
||||||
|
ALLOWED_USERS=123456789,987654321
|
||||||
```
|
```
|
||||||
|
|
||||||
باید پیام `Electrum daemon started. Launching bot...` را ببینید.
|
**فایل** — `allowed_users.txt`، هر کاربر در یک خط، به شکل `id, Name`:
|
||||||
|
```bash
|
||||||
|
cp allowed_users.txt.example allowed_users.txt
|
||||||
|
```
|
||||||
|
```
|
||||||
|
123456789, Alice
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر `allowed_users.txt` را مانت کنید ولی فایل وجود نداشته باشد، داکر بیسروصدا یک **پوشه** بهجای آن میسازد و هیچکس نمیتواند وارد شود. اکنون کانتینر این حالت را تشخیص میدهد و با پیام روشن متوقف میشود.
|
||||||
|
|
||||||
|
### انتشار ایمیج جدید
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./push.sh # نسخه از فایل VERSION خوانده میشود
|
||||||
|
./push.sh 1.0.1 # یا نسخه را مستقیم بدهید
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## حالت Tor
|
## حالتهای اتصال
|
||||||
|
|
||||||
حالت Tor، Electrum را از طریق شبکه Tor اجرا میکند. از یک فایل Docker Compose و Dockerfile جداگانه استفاده میکند تا راهاندازی استاندارد تحت تأثیر قرار نگیرد.
|
### استاندارد — سرورهای عمومی Electrum
|
||||||
|
|
||||||
### راهاندازی
|
حالت پیشفرض. برای اتصال فقط به یک سرور مشخص:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
ELECTRUM_SERVER=192.168.1.100:50001:t
|
||||||
|
```
|
||||||
|
|
||||||
|
### محلی / Umbrel — نود شخصی شما
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # مقادیر TELEGRAM_BOT_TOKEN و UMBREL_IP را تنظیم کنید
|
||||||
|
docker compose -f docker-compose.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> آدرس `umbrel.local` داخل کانتینر **کار نمیکند** — mDNS فقط روی خود میزبان در دسترس است. از IP واقعی استفاده کنید، مثلاً `192.168.1.100`.
|
||||||
|
|
||||||
|
در اولین اجرا فایل `./electrum-data/config` با همان سرور نوشته میشود. اگر فایل از قبل باشد دست نمیخورد، پس تغییرات دستی شما باقی میمانند.
|
||||||
|
|
||||||
|
> اگر وصل نشد، بررسی کنید رشتهٔ سرور در `./electrum-data/config` هنوز به `:t` (یعنی TCP) ختم شود. گاهی Electrum آن را به `:s` (SSL) تغییر میدهد. کانتینر را متوقف کنید، برگردانید و دوباره اجرا کنید.
|
||||||
|
|
||||||
|
### Tor
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.tor.example .env
|
cp .env.tor.example .env
|
||||||
nano .env
|
docker compose -f docker-compose.tor.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`TELEGRAM_BOT_TOKEN` را پر کنید و یکی از حالتهای اتصال را انتخاب کنید:
|
یا نود onion خودتان را بدهید:
|
||||||
|
```dotenv
|
||||||
**گزینه الف — نود Electrum شخصی از طریق Tor:**
|
|
||||||
|
|
||||||
```
|
|
||||||
ELECTRUM_SERVER=yournode123.onion:50001:t
|
ELECTRUM_SERVER=yournode123.onion:50001:t
|
||||||
```
|
```
|
||||||
|
…یا خالی بگذارید تا از سرور onion عمومی استفاده شود (`electrums3lojbuj.onion:50001:t`، قابل تغییر با `ELECTRUM_ONION_SERVER`).
|
||||||
|
|
||||||
**گزینه ب — سرور عمومی .onion (پیشفرض، نیاز به تنظیم اضافه ندارد):**
|
نکتهها:
|
||||||
|
- Tor داخل همان کانتینر اجرا میشود؛ entrypoint قبل از اجرای Electrum منتظر آمادهشدن پروکسی SOCKS میماند.
|
||||||
`ELECTRUM_SERVER` را خالی بگذارید. ربات به `electrums3lojbuj.onion:50001:t` وصل میشود. برای تغییر از `ELECTRUM_ONION_SERVER` استفاده کنید.
|
- **استعلام کارمزد هم از Tor عبور میکند.** از سرویس onion سایت mempool.space استفاده میشود، بنابراین بررسی کارمزد نمیتواند IP سرور شما را به فعالیت بیتکوینیتان ربط دهد.
|
||||||
|
- اطلاعات کیفپول در ولوم جداگانه (`electrum-tor-data`) نگهداری میشود.
|
||||||
### راهاندازی
|
- `:t` یعنی TCP — رمزنگاری را خود Tor انجام میدهد و SSL لازم نیست.
|
||||||
|
|
||||||
```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` |
|
| **Add Wallet** | افزودن کیفپول فقط-تماشا: نام ← xpub ← رمز. پیام حاوی xpub پس از خواندن حذف میشود. |
|
||||||
| Tor | `electrum-tor-data` |
|
| **List** | فهرست کیفپولهای شما. |
|
||||||
|
| **Load** | فعالکردن یک کیفپول (رمز میخواهد). برای Balance / Receive / Send لازم است. |
|
||||||
|
| **Balance** | موجودی تأییدشده و تأییدنشده. دکمههای شیشهای واحد را بین BTC / mBTC / ساتوشی عوض میکنند. |
|
||||||
|
| **Receive** | یک آدرس دریافت استفادهنشدهٔ تازه. |
|
||||||
|
| **Send** | ساخت تراکنش **امضانشده** — پایینتر توضیح داده شده. |
|
||||||
|
| **Close** | خارجکردن کیفپول فعال از دیمن. |
|
||||||
|
| **🌐 Language** | تغییر بین فارسی و انگلیسی. |
|
||||||
|
|
||||||
برای پشتیبانگیری (حالت استاندارد):
|
نام کیفپول میتواند شامل حروف، اعداد، فاصله، نقطه، خط تیره و زیرخط باشد (۱ تا ۶۴ کاراکتر) و باید با حرف یا عدد شروع شود. رمز باید حداقل ۸ کاراکتر و شامل حرف بزرگ، حرف کوچک و عدد باشد.
|
||||||
|
|
||||||
|
### ارسال
|
||||||
|
|
||||||
|
۱. **Send** ← انتخاب منبع کارمزد: **Electrum** یا **mempool.space** (زنده)
|
||||||
|
۲. نرخ کارمزد را بر حسب sat/vB وارد کنید
|
||||||
|
۳. رمز کیفپول را وارد کنید *(پیام بلافاصله حذف میشود)*
|
||||||
|
۴. آدرس مقصد را وارد کنید
|
||||||
|
۵. مبلغ را وارد کنید یا **Send Max** را بزنید
|
||||||
|
۶. تراکنش امضانشده را هم بهصورت متن و هم فایل `.txt` دریافت میکنید
|
||||||
|
۷. آن را آفلاین امضا کنید — الکترومِ اندروید: *Send ← Paste from clipboard*؛ نسخهٔ دسکتاپ: *Send ← آیکون ابزار ← Read text from file*
|
||||||
|
۸. **Send ← Broadcast** و تراکنش امضاشده را بچسبانید
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پشتیبانگیری
|
||||||
|
|
||||||
|
همه چیز — کیفپولها، تنظیمات و هدرهای بلاکچین — در یک ولوم است.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \
|
# حالت راهاندازی سریع
|
||||||
tar czf /backup/electrum-backup.tar.gz -C /data .
|
docker run --rm -v telegtrum-data:/data -v "$PWD":/backup debian:bookworm-slim \
|
||||||
|
tar czf /backup/telegtrum-backup.tar.gz -C /data .
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
| حالت | ولوم |
|
||||||
|
|---|---|
|
||||||
|
| راهاندازی سریع | `telegtrum-data` |
|
||||||
|
| ساخت از سورس | `electrum-data` |
|
||||||
|
| Tor | `electrum-tor-data` |
|
||||||
|
| محلی / Umbrel | `./electrum-data` (bind mount) |
|
||||||
|
|
||||||
## راهنمای استفاده از ربات
|
پشتیبان جای عبارت بازیابی (seed) را نمیگیرد. اینها کیفپول فقط-تماشا هستند؛ چیزی که واقعاً دارایی شما را برمیگرداند، seed روی دستگاه آفلاین شماست.
|
||||||
|
|
||||||
### شروع کار
|
|
||||||
|
|
||||||
ربات را در تلگرام باز کنید و هر پیامی ارسال کنید. یک کیبورد ثابت در پایین چت با تمام دکمهها نمایش داده میشود.
|
|
||||||
|
|
||||||
### افزودن کیفپول (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` بررسی کنید. مطمئن شوید توکن ربات درست است و کانتینر در حال اجراست.
|
**ربات پاسخ نمیدهد** — با `docker compose logs -f` لاگ را ببینید. توکن را بررسی کنید و مطمئن شوید کانتینر مدام ریاستارت نمیشود.
|
||||||
|
|
||||||
**"شما مجاز نیستید":** شناسه کاربری تلگرام شما در `allowed_users.txt` نیست. آن را اضافه کرده و ریستارت کنید.
|
**«شما مجاز به استفاده از این ربات نیستید»** — شناسهٔ شما در `ALLOWED_USERS` یا `allowed_users.txt` نیست. اضافه و ریاستارت کنید.
|
||||||
|
|
||||||
**خطای کیفپول بارگذارینشده:** قبل از بررسی موجودی، دریافت یا ارسال، از **Load** استفاده کنید.
|
**همه رد میشوند** — اگر هر دو منبع خالی باشند، کانتینر هنگام اجرا پیام `no allowed users configured` را ثبت میکند.
|
||||||
|
|
||||||
**دیمن شروع نمیشود:** کانتینر lockfile را هنگام راهاندازی خودکار حذف میکند. اگر مشکل ادامه داشت، `docker compose restart` را امتحان کنید.
|
**«کیفپولی بارگذاری نشده»** — اول **Load** را بزنید؛ Balance و Receive و Send همگی به کیفپول فعال نیاز دارند.
|
||||||
|
|
||||||
**حالت Tor — Electrum وصل نمیشود:** bootstrap اولیه Tor تا ۳۰ ثانیه طول میکشد. لاگها را با `docker compose -f docker-compose.tor.yml logs -f` بررسی کنید.
|
**نام کیفپول نامعتبر** — فقط حروف، اعداد، فاصله، نقطه، خط تیره و زیرخط.
|
||||||
|
|
||||||
|
**دیمن اجرا نمیشود** — entrypoint در هر اجرا فایل قفل قدیمی را پاک میکند. اگر باز هم بود، `docker compose restart` بزنید.
|
||||||
|
|
||||||
|
**Tor وصل نمیشود** — راهاندازی اولیهٔ Tor ممکن است حدود ۳۰ ثانیه طول بکشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اطمینان از چیزی که اجرا میکنید
|
||||||
|
|
||||||
|
ایمیج، Electrum را از روی تاربالِ رسمی میسازد و **امضای GPG آن را با اثرانگشتهای پینشدهٔ کلیدهای انتشار** (ThomasV و sombernight و Emzy) بررسی میکند. اگر امضا نباشد یا اثرانگشت نخواند، build شکست میخورد و کیفپول تأییدنشده منتشر نمیشود.
|
||||||
|
|
||||||
|
میتوانید خودتان با `./push.sh` بازتولیدش کنید — یا فایل [`install.sh`](install.sh) را بخوانید، فقط ۶۰ خط است.
|
||||||
|
|||||||
@@ -1,299 +1,226 @@
|
|||||||
# Online Wallet Bot
|
# Telegtrum
|
||||||
|
|
||||||
A self-hosted Telegram bot for managing Bitcoin wallets using Electrum. Designed for watch-only (xpub) wallets with air-gapped signing support.
|
**Telegram + Electrum** — a self-hosted Telegram bot for managing Bitcoin wallets with Electrum.
|
||||||
|
|
||||||
## Features
|
Telegtrum is built for **watch-only (xpub) wallets with air-gapped signing**. Your private keys never touch the server. The bot can see balances, hand out receiving addresses and build *unsigned* transactions; only your offline device can sign them.
|
||||||
|
|
||||||
- Per-user isolated wallets
|
---
|
||||||
- Watch-only wallet restore from xpub
|
|
||||||
- Balance display (Confirmed / Unconfirmed)
|
## Security model — read this first
|
||||||
- Receive address generation
|
|
||||||
- Unsigned transaction (PSBT) creation with fee options
|
| | |
|
||||||
- Unit switching: BTC / mBTC / sats
|
|---|---|
|
||||||
- Persian (Farsi) and English language support
|
| **The server never holds private keys** | You import an **xpub**. It can watch, not spend. |
|
||||||
- Persistent bottom keyboard
|
| **Spending needs your offline device** | The bot produces an unsigned transaction; you sign it elsewhere and paste it back to broadcast. |
|
||||||
- Optional Tor support (connect via .onion to a public server or your own node)
|
| **Access is deny-by-default** | Only Telegram user IDs you list may use the bot. An empty list refuses everyone. |
|
||||||
|
| **Secrets are scrubbed from chat** | Wallet passwords and your xpub are deleted from the conversation right after they're read. |
|
||||||
|
| **Wallets are isolated per user** | Each Telegram user gets their own directory; names are validated so one user cannot reach another's files. |
|
||||||
|
|
||||||
|
**What this does not protect against.** Anyone who controls your Telegram account can see your balances and addresses, and can build transactions (they still cannot sign them). Anyone with root on the server can read wallet files and see wallet passwords in the process list while an Electrum command runs — so treat the server as trusted infrastructure and keep the allow-list short. Your xpub is a privacy secret: whoever has it can see your entire transaction history forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick deploy (recommended)
|
||||||
|
|
||||||
|
No source checkout, no build. Two files and a `docker compose up`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir telegtrum && cd telegtrum
|
||||||
|
|
||||||
|
curl -O https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/docker-compose.deploy.yml
|
||||||
|
curl -o .env https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/.env.deploy.example
|
||||||
|
|
||||||
|
nano .env # add your bot token and your Telegram user ID
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
docker compose -f docker-compose.deploy.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Your `.env` needs two things:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC... # from @BotFather
|
||||||
|
ALLOWED_USERS=123456789 # from @userinfobot, comma-separated for several
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose refuses to start if `ALLOWED_USERS` is unset, rather than launching a bot nobody can use.
|
||||||
|
|
||||||
|
You should see `Electrum daemon started. Launching bot...`. Message your bot on Telegram and press **Add Wallet**.
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.deploy.yml pull
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Wallet data lives in the `telegtrum-data` volume and survives updates.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Linux server with Docker and Docker Compose installed
|
- A Linux server with Docker and Docker Compose
|
||||||
- A Telegram bot token (from [@BotFather](https://t.me/BotFather))
|
- A Telegram bot token — [@BotFather](https://t.me/BotFather)
|
||||||
- Your Telegram user ID (from [@userinfobot](https://t.me/userinfobot))
|
- Your Telegram user ID — [@userinfobot](https://t.me/userinfobot)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Server Setup (Step by Step)
|
## Building from source
|
||||||
|
|
||||||
### 1. Clone the repository
|
Only needed if you want to modify the bot.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.goyban.com/goyban/telegtrum_bot.git
|
git clone https://git.goyban.com/goyban/telegtrum_bot.git
|
||||||
cd telegtrum_bot
|
cd telegtrum_bot
|
||||||
|
cp .env.example .env && nano .env
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Create your `.env` file
|
The source compose files mount `bot.py` read-only, so you can edit it and `docker compose restart` without rebuilding.
|
||||||
|
|
||||||
```bash
|
### Allow-list: file or environment
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
Either works; if you use both, the two lists are merged.
|
||||||
|
|
||||||
|
**Environment** (simplest — nothing to mount):
|
||||||
|
```dotenv
|
||||||
|
ALLOWED_USERS=123456789,987654321
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill in:
|
**File** — `allowed_users.txt`, one user per line, `id, Name`:
|
||||||
|
|
||||||
```
|
|
||||||
ELECTRUM_VERSION=4.7.2
|
|
||||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add your Telegram user ID to the allowed users list
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp allowed_users.txt.example allowed_users.txt
|
cp allowed_users.txt.example allowed_users.txt
|
||||||
nano allowed_users.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a line in the format `userid, Name`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
123456789, Alice
|
123456789, Alice
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find your user ID by messaging [@userinfobot](https://t.me/userinfobot) on Telegram.
|
> If you bind-mount `allowed_users.txt` and the file doesn't exist, Docker silently creates a **directory** in its place and nobody can log in. The container now detects this and stops with an explanation instead of starting unusable.
|
||||||
|
|
||||||
### 4. Build and start the container
|
### Publishing a new image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
./push.sh # tag comes from the VERSION file
|
||||||
|
./push.sh 1.0.1 # or pass one explicitly
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Local / Umbrel Mode
|
## Connection modes
|
||||||
|
|
||||||
Use this mode to connect to your own Electrum server running on Umbrel (or any local node) on your home network.
|
### Standard — public Electrum servers
|
||||||
|
|
||||||
> **Note:** `umbrel.local` does not resolve inside Docker containers — mDNS is host-only. Use the actual **IP address** of your Umbrel device (e.g. `192.168.1.100`).
|
The default. Electrum picks public peers. Pin it to one server instead:
|
||||||
|
|
||||||
### Setup
|
```dotenv
|
||||||
|
ELECTRUM_SERVER=192.168.1.100:50001:t
|
||||||
```bash
|
```
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
### Local / Umbrel — your own node
|
||||||
```
|
|
||||||
|
|
||||||
Fill in `TELEGRAM_BOT_TOKEN` and set your Umbrel's IP:
|
|
||||||
|
|
||||||
```
|
|
||||||
UMBREL_IP=192.168.1.100
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp allowed_users.txt.example allowed_users.txt
|
|
||||||
nano allowed_users.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env # set TELEGRAM_BOT_TOKEN and UMBREL_IP
|
||||||
docker compose -f docker-compose.local.yml up -d --build
|
docker compose -f docker-compose.local.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### How it works
|
> `umbrel.local` will **not** resolve inside a container — mDNS is host-only. Use the actual IP, e.g. `192.168.1.100`.
|
||||||
|
|
||||||
On first start, the entrypoint writes `./electrum-data/config` with:
|
On first start the entrypoint writes `./electrum-data/config` pinning that server. An existing config is left alone, so manual edits survive restarts.
|
||||||
|
|
||||||
```json
|
> If it won't connect, check that the server string in `./electrum-data/config` still ends in `:t` (TCP). Electrum sometimes rewrites it to `:s` (SSL). Stop the container, change it back, restart.
|
||||||
{
|
|
||||||
"auto_connect": false,
|
|
||||||
"enable_proxy": false,
|
|
||||||
"oneserver": true,
|
|
||||||
"server": "192.168.1.100:50001:t"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the config file already exists it is left untouched, so manual edits survive restarts.
|
### Tor
|
||||||
|
|
||||||
> **If the connection doesn't work:** check `./electrum-data/config` after the first run and make sure the server string still ends in `:t` (TCP). Electrum may rewrite it to `:s` (SSL) on first connect. If it did, stop the container, change it back to `:t`, and restart.
|
|
||||||
|
|
||||||
### Check logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.local.yml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cp .env.tor.example .env
|
cp .env.tor.example .env
|
||||||
nano .env
|
docker compose -f docker-compose.tor.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill in `TELEGRAM_BOT_TOKEN` and choose a connection mode:
|
Either point it at your own onion node:
|
||||||
|
```dotenv
|
||||||
**Option A — your own Electrum server over Tor:**
|
|
||||||
|
|
||||||
```
|
|
||||||
ELECTRUM_SERVER=yournode123.onion:50001:t
|
ELECTRUM_SERVER=yournode123.onion:50001:t
|
||||||
```
|
```
|
||||||
|
…or leave it unset to use a public onion server (`electrums3lojbuj.onion:50001:t`, override with `ELECTRUM_ONION_SERVER`).
|
||||||
|
|
||||||
**Option B — public .onion server (default, no extra config needed):**
|
Notes:
|
||||||
|
- Tor runs inside the same container; the entrypoint waits for the SOCKS proxy before starting Electrum.
|
||||||
Leave `ELECTRUM_SERVER` unset. The bot will connect to `electrums3lojbuj.onion:50001:t` by default. Override with `ELECTRUM_ONION_SERVER` if needed.
|
- **The fee lookup goes through Tor too.** It uses mempool.space's onion service, so checking fees cannot tie your server's IP to your Bitcoin activity.
|
||||||
|
- Wallet data uses its own volume (`electrum-tor-data`), kept separate from standard mode.
|
||||||
### Start
|
- `:t` means TCP — Tor provides the encryption, so SSL is not needed.
|
||||||
|
|
||||||
```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
|
## Using the bot
|
||||||
|
|
||||||
The `bot.py` file is mounted as a volume, so you can update it without rebuilding the image:
|
A persistent keyboard sits at the bottom of the chat.
|
||||||
|
|
||||||
```bash
|
| Button | What it does |
|
||||||
# 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` |
|
| **Add Wallet** | Import a watch-only wallet: name → xpub → password. Your xpub message is deleted once read. |
|
||||||
| Tor | `electrum-tor-data` |
|
| **List** | Your wallets. |
|
||||||
|
| **Load** | Activate a wallet (asks for its password). Needed before Balance / Receive / Send. |
|
||||||
|
| **Balance** | Confirmed and unconfirmed balance. Inline buttons switch BTC / mBTC / sats. |
|
||||||
|
| **Receive** | A fresh unused receiving address. |
|
||||||
|
| **Send** | Builds an *unsigned* transaction — see below. |
|
||||||
|
| **Close** | Unload the active wallet from the daemon. |
|
||||||
|
| **🌐 Language** | English ⇄ Persian. |
|
||||||
|
|
||||||
To back up wallet data (standard mode):
|
Wallet names may use letters, numbers, spaces, dot, dash and underscore (1–64 characters) and must start with a letter or number. Passwords need at least 8 characters with an uppercase letter, a lowercase letter and a digit.
|
||||||
|
|
||||||
```bash
|
### Sending
|
||||||
docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \
|
|
||||||
tar czf /backup/electrum-backup.tar.gz -C /data .
|
1. **Send** → choose a fee source: **Electrum** (its own estimate) or **mempool.space** (live)
|
||||||
```
|
2. Enter the fee rate in sat/vB
|
||||||
|
3. Enter the wallet password *(message deleted immediately)*
|
||||||
|
4. Enter the recipient address
|
||||||
|
5. Enter the amount, or tap **Send Max**
|
||||||
|
6. You get the unsigned transaction as both a code block and a `.txt` file
|
||||||
|
7. Sign it offline — Electrum Android: *Send → Paste from clipboard*; Electrum desktop: *Send → tools icon → Read text from file*
|
||||||
|
8. **Send → Broadcast**, paste the signed transaction
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bot Usage Guide
|
## Backup
|
||||||
|
|
||||||
### Starting the bot
|
Everything — wallets, config, blockchain headers — is in one volume.
|
||||||
|
|
||||||
Open the bot in Telegram and send any message. You'll see a persistent keyboard at the bottom with all available actions.
|
```bash
|
||||||
|
# Quick deploy
|
||||||
|
docker run --rm -v telegtrum-data:/data -v "$PWD":/backup debian:bookworm-slim \
|
||||||
|
tar czf /backup/telegtrum-backup.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
### Add Wallet
|
| Mode | Volume |
|
||||||
|
|---|---|
|
||||||
|
| Quick deploy | `telegtrum-data` |
|
||||||
|
| Build from source | `electrum-data` |
|
||||||
|
| Tor | `electrum-tor-data` |
|
||||||
|
| Local / Umbrel | `./electrum-data` (bind mount) |
|
||||||
|
|
||||||
Import a watch-only wallet from an xpub (extended public key):
|
A backup is not a substitute for your seed phrase. These are watch-only wallets — the seed on your offline device is what actually recovers your coins.
|
||||||
|
|
||||||
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
|
## Troubleshooting
|
||||||
|
|
||||||
**Bot not responding:** Check logs with `docker compose logs -f`. Make sure your bot token is correct and the container is running.
|
**Bot not responding** — `docker compose logs -f`. Check the token, and that the container isn't restarting.
|
||||||
|
|
||||||
**"You are not authorized":** Your Telegram user ID is not in `allowed_users.txt`. Add it and restart.
|
**"You are not authorized"** — your Telegram ID isn't in `ALLOWED_USERS` or `allowed_users.txt`. Add it and restart.
|
||||||
|
|
||||||
**Wallet not loaded error:** Use **Load** before checking balance, receiving, or sending.
|
**Everyone is refused** — the container logs `no allowed users configured` at startup when both sources are empty.
|
||||||
|
|
||||||
**Daemon not starting:** The container removes the lockfile on startup automatically. If issues persist, try `docker compose restart`.
|
**"Wallet not loaded"** — press **Load** first; Balance, Receive and Send all need an active wallet.
|
||||||
|
|
||||||
**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`.
|
**Invalid wallet name** — stick to letters, numbers, spaces, dot, dash, underscore.
|
||||||
|
|
||||||
|
**Daemon won't start** — the entrypoint clears the stale lockfile each boot. If it persists, `docker compose restart`.
|
||||||
|
|
||||||
|
**Tor: not connecting** — bootstrap can take ~30s on first run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifying what you run
|
||||||
|
|
||||||
|
The image builds Electrum from the official source tarball and **verifies its GPG signature against pinned release-key fingerprints** (ThomasV, sombernight, Emzy). If the signature is missing or the fingerprint doesn't match, the build fails rather than shipping an unverified wallet.
|
||||||
|
|
||||||
|
Reproduce it yourself with `./push.sh` — or read [`install.sh`](install.sh), it's 60 lines.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import io
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update
|
||||||
@@ -27,9 +29,18 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
|
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
|
||||||
ALLOWED_USERS_FILE = os.environ.get("ALLOWED_USERS_FILE", "/app/allowed_users.txt")
|
ALLOWED_USERS_FILE = os.environ.get("ALLOWED_USERS_FILE", "/app/allowed_users.txt")
|
||||||
|
# Comma/space separated IDs. Lets a deployment work with no bind-mounted file.
|
||||||
|
ALLOWED_USERS_ENV = os.environ.get("ALLOWED_USERS", "")
|
||||||
WALLETS_BASE = "/root/.electrum/wallets"
|
WALLETS_BASE = "/root/.electrum/wallets"
|
||||||
SETTINGS_FILE = "/root/.electrum/bot_settings.json"
|
SETTINGS_FILE = "/root/.electrum/bot_settings.json"
|
||||||
|
|
||||||
|
# Fee lookup. In Tor mode the entrypoint points these at the mempool.space onion
|
||||||
|
# service and Tor's SOCKS port, so the fee check never touches the clear net.
|
||||||
|
MEMPOOL_API_URL = os.environ.get(
|
||||||
|
"MEMPOOL_API_URL", "https://mempool.space/api/v1/fees/recommended"
|
||||||
|
)
|
||||||
|
TOR_SOCKS = os.environ.get("TOR_SOCKS", "")
|
||||||
|
|
||||||
|
|
||||||
# ── Translations ──────────────────────────────────────────────────────────────
|
# ── Translations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +61,11 @@ STRINGS: dict[str, dict[str, str]] = {
|
|||||||
"Use the buttons below to get started."
|
"Use the buttons below to get started."
|
||||||
),
|
),
|
||||||
"not_authorized": "You are not authorized to use this bot.",
|
"not_authorized": "You are not authorized to use this bot.",
|
||||||
|
"invalid_wallet_name": ("❌ Invalid wallet name.\n\n"
|
||||||
|
"Use 1–64 characters: letters, numbers, spaces, dot, dash or underscore. "
|
||||||
|
"It must start with a letter or number.\n\nTry again:"),
|
||||||
|
"wallet_exists": "❌ You already have a wallet named '{name}'. Choose another name:",
|
||||||
|
"xpub_received": "✅ Master public key received — your message was deleted for privacy.",
|
||||||
"cancelled": "Cancelled.",
|
"cancelled": "Cancelled.",
|
||||||
"cancel_for_button": "Previous operation cancelled. Tap the button again.",
|
"cancel_for_button": "Previous operation cancelled. Tap the button again.",
|
||||||
"your_wallets": "Your wallets:",
|
"your_wallets": "Your wallets:",
|
||||||
@@ -146,6 +162,11 @@ STRINGS: dict[str, dict[str, str]] = {
|
|||||||
"از دکمههای زیر شروع کنید."
|
"از دکمههای زیر شروع کنید."
|
||||||
),
|
),
|
||||||
"not_authorized": "شما مجاز به استفاده از این ربات نیستید.",
|
"not_authorized": "شما مجاز به استفاده از این ربات نیستید.",
|
||||||
|
"invalid_wallet_name": ("❌ نام کیفپول نامعتبر است.\n\n"
|
||||||
|
"از ۱ تا ۶۴ کاراکتر استفاده کنید: حروف، اعداد، فاصله، نقطه، خط تیره یا زیرخط. "
|
||||||
|
"باید با حرف یا عدد شروع شود.\n\nدوباره تلاش کنید:"),
|
||||||
|
"wallet_exists": "❌ کیفپولی با نام '{name}' از قبل دارید. نام دیگری انتخاب کنید:",
|
||||||
|
"xpub_received": "✅ کلید عمومی اصلی دریافت شد — پیام شما برای حفظ حریم خصوصی حذف شد.",
|
||||||
"cancelled": "لغو شد.",
|
"cancelled": "لغو شد.",
|
||||||
"cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.",
|
"cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.",
|
||||||
"your_wallets": "کیف پولهای شما:",
|
"your_wallets": "کیف پولهای شما:",
|
||||||
@@ -248,9 +269,24 @@ def _load_settings() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _save_settings(settings: dict) -> None:
|
def _save_settings(settings: dict) -> None:
|
||||||
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
|
# Written via a temp file + atomic rename: a crash half-way through can
|
||||||
with open(SETTINGS_FILE, "w") as f:
|
# otherwise leave a truncated settings file, losing every user's wallet
|
||||||
json.dump(settings, f)
|
# selection and language.
|
||||||
|
d = os.path.dirname(SETTINGS_FILE)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=d, prefix=".bot_settings.", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(settings, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, SETTINGS_FILE)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def get_user_unit(user_id: int) -> str:
|
def get_user_unit(user_id: int) -> str:
|
||||||
@@ -388,6 +424,10 @@ S_MENU, S_FEE_SRC, S_RATE, S_PASS, S_ADDR, S_AMT, S_BCAST = range(7)
|
|||||||
|
|
||||||
def load_allowed_users() -> set[int]:
|
def load_allowed_users() -> set[int]:
|
||||||
users: set[int] = set()
|
users: set[int] = set()
|
||||||
|
for chunk in re.split(r"[,\s]+", ALLOWED_USERS_ENV):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if chunk.isdigit():
|
||||||
|
users.add(int(chunk))
|
||||||
try:
|
try:
|
||||||
with open(ALLOWED_USERS_FILE) as f:
|
with open(ALLOWED_USERS_FILE) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
@@ -397,8 +437,11 @@ def load_allowed_users() -> set[int]:
|
|||||||
uid = line.split(",", 1)[0].strip()
|
uid = line.split(",", 1)[0].strip()
|
||||||
if uid.isdigit():
|
if uid.isdigit():
|
||||||
users.add(int(uid))
|
users.add(int(uid))
|
||||||
except FileNotFoundError:
|
except (FileNotFoundError, IsADirectoryError, PermissionError):
|
||||||
logger.warning("Allowed users file not found: %s", ALLOWED_USERS_FILE)
|
# Not fatal — ALLOWED_USERS may be supplying the list instead
|
||||||
|
logger.warning("Allowed users file unreadable: %s", ALLOWED_USERS_FILE)
|
||||||
|
if not users:
|
||||||
|
logger.warning("No allowed users configured — the bot will reject everyone")
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
@@ -408,8 +451,32 @@ def user_wallet_path(user_id: int) -> str:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# Wallet names become file names. Without this a name like "../<other-id>/x"
|
||||||
|
# would read or overwrite another user's wallet, or clobber bot_settings.json.
|
||||||
|
WALLET_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_wallet_name(name: str) -> bool:
|
||||||
|
return bool(name) and bool(WALLET_NAME_RE.fullmatch(name)) and "/" not in name
|
||||||
|
|
||||||
|
|
||||||
|
def wallet_file(user_id: int, name: str) -> str | None:
|
||||||
|
"""Absolute path of a wallet, or None if the name escapes the user's dir."""
|
||||||
|
if not is_valid_wallet_name(name):
|
||||||
|
return None
|
||||||
|
base = os.path.realpath(user_wallet_path(user_id))
|
||||||
|
path = os.path.realpath(os.path.join(base, name))
|
||||||
|
# Belt and braces: even a name that passed the regex must resolve inside base
|
||||||
|
if path != base and not path.startswith(base + os.sep):
|
||||||
|
logger.warning("Rejected wallet path outside user dir: user=%s name=%r", user_id, name)
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def list_user_wallets(user_id: int) -> list[str]:
|
def list_user_wallets(user_id: int) -> list[str]:
|
||||||
return sorted(os.listdir(user_wallet_path(user_id)))
|
return sorted(
|
||||||
|
w for w in os.listdir(user_wallet_path(user_id)) if is_valid_wallet_name(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_electrum(*args: str) -> tuple[str, str, int]:
|
def run_electrum(*args: str) -> tuple[str, str, int]:
|
||||||
@@ -420,11 +487,26 @@ def run_electrum(*args: str) -> tuple[str, str, int]:
|
|||||||
|
|
||||||
|
|
||||||
async def auth_check(update: Update) -> bool:
|
async def auth_check(update: Update) -> bool:
|
||||||
|
"""Gate every entry point. Also called from callback handlers, where
|
||||||
|
update.message is None, so the reply has to go through the query."""
|
||||||
uid = update.effective_user.id
|
uid = update.effective_user.id
|
||||||
if uid not in load_allowed_users():
|
if uid in load_allowed_users():
|
||||||
|
return True
|
||||||
|
if update.callback_query:
|
||||||
|
await update.callback_query.answer(t(uid, "not_authorized"), show_alert=True)
|
||||||
|
elif update.message:
|
||||||
await update.message.reply_text(t(uid, "not_authorized"))
|
await update.message.reply_text(t(uid, "not_authorized"))
|
||||||
return False
|
return False
|
||||||
return True
|
|
||||||
|
|
||||||
|
async def _delete_quietly(message) -> None:
|
||||||
|
"""Best-effort removal of a message holding a secret. Telegram refuses for
|
||||||
|
messages older than 48h, and the secret is still worth scrubbing when it
|
||||||
|
works, so a failure here must not abort the flow."""
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as e: # noqa: BLE001 - never let cleanup break the conversation
|
||||||
|
logger.warning("Could not delete sensitive message: %s", e)
|
||||||
|
|
||||||
|
|
||||||
async def reply(update: Update, text: str, **kwargs) -> None:
|
async def reply(update: Update, text: str, **kwargs) -> None:
|
||||||
@@ -484,7 +566,10 @@ async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
|
|||||||
return
|
return
|
||||||
unit = get_user_unit(uid)
|
unit = get_user_unit(uid)
|
||||||
lang = get_user_lang(uid)
|
lang = get_user_lang(uid)
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
|
wallet_path = wallet_file(uid, get_loaded_wallet(uid))
|
||||||
|
if wallet_path is None:
|
||||||
|
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
|
||||||
|
return
|
||||||
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
|
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
|
await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
|
||||||
@@ -499,6 +584,8 @@ async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not await auth_check(update):
|
||||||
|
return
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
uid = query.from_user.id
|
uid = query.from_user.id
|
||||||
@@ -509,7 +596,10 @@ async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
|
|||||||
if not loaded:
|
if not loaded:
|
||||||
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
|
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
|
||||||
return
|
return
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
|
wallet_path = wallet_file(uid, loaded)
|
||||||
|
if wallet_path is None:
|
||||||
|
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
|
||||||
|
return
|
||||||
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
|
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
await query.edit_message_text(t(uid, "error", error=stderr or stdout))
|
await query.edit_message_text(t(uid, "error", error=stderr or stdout))
|
||||||
@@ -530,7 +620,10 @@ async def receive_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
|
|||||||
if not loaded:
|
if not loaded:
|
||||||
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
|
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
|
||||||
return
|
return
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
|
wallet_path = wallet_file(uid, loaded)
|
||||||
|
if wallet_path is None:
|
||||||
|
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
|
||||||
|
return
|
||||||
stdout, stderr, rc = run_electrum("getunusedaddress", "-w", wallet_path)
|
stdout, stderr, rc = run_electrum("getunusedaddress", "-w", wallet_path)
|
||||||
msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout)
|
msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout)
|
||||||
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
|
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
|
||||||
@@ -544,6 +637,8 @@ async def lang_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def lang_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def lang_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
if not await auth_check(update):
|
||||||
|
return
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
uid = query.from_user.id
|
uid = query.from_user.id
|
||||||
@@ -563,36 +658,53 @@ async def restore_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i
|
|||||||
|
|
||||||
|
|
||||||
async def restore_got_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
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
|
||||||
uid = update.effective_user.id
|
name = update.message.text.strip()
|
||||||
|
if not is_valid_wallet_name(name):
|
||||||
|
await update.message.reply_text(t(uid, "invalid_wallet_name"))
|
||||||
|
return RESTORE_NAME
|
||||||
|
if name in list_user_wallets(uid):
|
||||||
|
await update.message.reply_text(t(uid, "wallet_exists", name=name))
|
||||||
|
return RESTORE_NAME
|
||||||
|
context.user_data["walletname"] = name
|
||||||
await update.message.reply_text(t(uid, "enter_xpub"))
|
await update.message.reply_text(t(uid, "enter_xpub"))
|
||||||
return RESTORE_XPUB
|
return RESTORE_XPUB
|
||||||
|
|
||||||
|
|
||||||
async def restore_got_pubkey(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
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
|
uid = update.effective_user.id
|
||||||
await update.message.reply_text(t(uid, "enter_password"))
|
context.user_data["pubkey"] = update.message.text.strip()
|
||||||
|
# The xpub reveals every address and the full balance history of the wallet.
|
||||||
|
# Don't leave it sitting in the Telegram chat forever.
|
||||||
|
await _delete_quietly(update.message)
|
||||||
|
await context.bot.send_message(update.effective_chat.id, t(uid, "xpub_received"))
|
||||||
|
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_password"))
|
||||||
return RESTORE_PASSWORD
|
return RESTORE_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
async def restore_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
async def restore_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
uid = update.effective_user.id
|
uid = update.effective_user.id
|
||||||
password = update.message.text.strip()
|
password = update.message.text.strip()
|
||||||
await update.message.delete()
|
await _delete_quietly(update.message)
|
||||||
if not validate_password(password):
|
if not validate_password(password):
|
||||||
await context.bot.send_message(update.effective_chat.id, t(uid, "password_invalid"))
|
await context.bot.send_message(update.effective_chat.id, t(uid, "password_invalid"))
|
||||||
return RESTORE_PASSWORD
|
return RESTORE_PASSWORD
|
||||||
context.user_data["password"] = password
|
context.user_data["password"] = password
|
||||||
walletname = context.user_data["walletname"]
|
walletname = context.user_data["walletname"]
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
|
wallet_path = wallet_file(uid, walletname)
|
||||||
await update.message.reply_text(t(uid, "restoring", name=walletname))
|
if wallet_path is None:
|
||||||
|
await context.bot.send_message(update.effective_chat.id, t(uid, "invalid_wallet_name"),
|
||||||
|
reply_markup=MAIN_KEYBOARD)
|
||||||
|
context.user_data.clear()
|
||||||
|
return ConversationHandler.END
|
||||||
|
# The originating message is gone, so send rather than reply
|
||||||
|
await context.bot.send_message(update.effective_chat.id, t(uid, "restoring", name=walletname))
|
||||||
stdout, stderr, rc = run_electrum(
|
stdout, stderr, rc = run_electrum(
|
||||||
"restore", "--password", context.user_data["password"],
|
"restore", "--password", context.user_data["password"],
|
||||||
"--encrypt_file", "true", "-w", wallet_path, context.user_data["pubkey"],
|
"--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)
|
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)
|
await context.bot.send_message(update.effective_chat.id, msg, reply_markup=MAIN_KEYBOARD)
|
||||||
context.user_data.clear()
|
context.user_data.clear()
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
@@ -618,7 +730,13 @@ async def op_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
|||||||
|
|
||||||
async def op_wallet_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
async def op_wallet_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
await update.callback_query.answer()
|
await update.callback_query.answer()
|
||||||
return await _wallet_selected(update, context, update.callback_query.data)
|
uid = update.effective_user.id
|
||||||
|
name = update.callback_query.data
|
||||||
|
# callback_data is echoed back by the client — confirm it names a real wallet
|
||||||
|
if name not in list_user_wallets(uid):
|
||||||
|
await update.callback_query.edit_message_text(t(uid, "invalid_wallet_name"))
|
||||||
|
return ConversationHandler.END
|
||||||
|
return await _wallet_selected(update, context, name)
|
||||||
|
|
||||||
|
|
||||||
async def _wallet_selected(
|
async def _wallet_selected(
|
||||||
@@ -634,7 +752,7 @@ async def _wallet_selected(
|
|||||||
|
|
||||||
async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
context.user_data["password"] = update.message.text.strip()
|
context.user_data["password"] = update.message.text.strip()
|
||||||
await update.message.delete()
|
await _delete_quietly(update.message)
|
||||||
return await _execute_op(update, context)
|
return await _execute_op(update, context)
|
||||||
|
|
||||||
|
|
||||||
@@ -642,7 +760,11 @@ async def _execute_op(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
|
|||||||
uid = update.effective_user.id
|
uid = update.effective_user.id
|
||||||
op = context.user_data.get("op", "")
|
op = context.user_data.get("op", "")
|
||||||
walletname = context.user_data.get("walletname", "")
|
walletname = context.user_data.get("walletname", "")
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
|
wallet_path = wallet_file(uid, walletname)
|
||||||
|
if wallet_path is None:
|
||||||
|
await reply(update, t(uid, "invalid_wallet_name"))
|
||||||
|
context.user_data.clear()
|
||||||
|
return ConversationHandler.END
|
||||||
cmd_map = {
|
cmd_map = {
|
||||||
"load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")),
|
"load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")),
|
||||||
"close": ("close_wallet", "-w", wallet_path),
|
"close": ("close_wallet", "-w", wallet_path),
|
||||||
@@ -711,10 +833,15 @@ async def fee_mempool(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
|
|||||||
await query.answer()
|
await query.answer()
|
||||||
uid = query.from_user.id
|
uid = query.from_user.id
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
cmd = ["curl", "-sSL", "--max-time", "15"]
|
||||||
["curl", "-sSL", "https://mempool.space/api/v1/fees/recommended"],
|
if TOR_SOCKS:
|
||||||
capture_output=True, text=True, timeout=10,
|
# --socks5-hostname keeps DNS inside Tor too, so the lookup never
|
||||||
)
|
# leaks either. Set by the Tor entrypoint.
|
||||||
|
cmd += ["--socks5-hostname", TOR_SOCKS]
|
||||||
|
cmd.append(MEMPOOL_API_URL)
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
|
if res.returncode != 0:
|
||||||
|
raise RuntimeError(res.stderr.strip() or "fee lookup failed")
|
||||||
d = json.loads(res.stdout)
|
d = json.loads(res.stdout)
|
||||||
msg = t(uid, "fee_mempool_msg",
|
msg = t(uid, "fee_mempool_msg",
|
||||||
fastest=d["fastestFee"], half_hour=d["halfHourFee"],
|
fastest=d["fastestFee"], half_hour=d["halfHourFee"],
|
||||||
@@ -745,7 +872,7 @@ async def send_got_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -
|
|||||||
async def send_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
async def send_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
context.user_data["password"] = update.message.text.strip()
|
context.user_data["password"] = update.message.text.strip()
|
||||||
uid = update.effective_user.id
|
uid = update.effective_user.id
|
||||||
await update.message.delete()
|
await _delete_quietly(update.message)
|
||||||
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_addr"))
|
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_addr"))
|
||||||
return S_ADDR
|
return S_ADDR
|
||||||
|
|
||||||
@@ -782,7 +909,11 @@ async def _build_tx(update: Update, context: ContextTypes.DEFAULT_TYPE, btc_amou
|
|||||||
notify = update.callback_query.message if update.callback_query else update.message
|
notify = update.callback_query.message if update.callback_query else update.message
|
||||||
await notify.reply_text(t(uid, "creating_tx"))
|
await notify.reply_text(t(uid, "creating_tx"))
|
||||||
|
|
||||||
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
|
wallet_path = wallet_file(uid, get_loaded_wallet(uid))
|
||||||
|
if wallet_path is None:
|
||||||
|
await notify.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
|
||||||
|
context.user_data.clear()
|
||||||
|
return ConversationHandler.END
|
||||||
stdout, stderr, rc = run_electrum(
|
stdout, stderr, rc = run_electrum(
|
||||||
"payto",
|
"payto",
|
||||||
"-w", wallet_path,
|
"-w", wallet_path,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Ready-to-deploy Telegtrum. Pulls the published image — no source checkout,
|
||||||
|
# no build, no bind mounts.
|
||||||
|
#
|
||||||
|
# curl -O https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/docker-compose.deploy.yml
|
||||||
|
# curl -O https://git.goyban.com/goyban/telegtrum_bot/raw/branch/main/.env.deploy.example
|
||||||
|
# mv .env.deploy.example .env && nano .env
|
||||||
|
# docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
services:
|
||||||
|
telegtrum:
|
||||||
|
container_name: telegtrum
|
||||||
|
image: git.goyban.com/goyban/telegtrum:latest
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Allow-list comes from .env, so there is no file to create or mount.
|
||||||
|
ALLOWED_USERS: ${ALLOWED_USERS:?set ALLOWED_USERS in .env - comma separated Telegram user IDs}
|
||||||
|
ALLOWED_USERS_FILE: /nonexistent
|
||||||
|
volumes:
|
||||||
|
# Wallets, Electrum config and the blockchain headers live here.
|
||||||
|
# Back this up: docker run --rm -v telegtrum-data:/d -v "$PWD":/b \
|
||||||
|
# debian:bookworm-slim tar czf /b/telegtrum-backup.tar.gz -C /d .
|
||||||
|
- telegtrum-data:/root/.electrum
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "electrum", "getinfo"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
telegtrum-data:
|
||||||
@@ -5,6 +5,20 @@ set -e
|
|||||||
|
|
||||||
mkdir -p /root/.electrum/wallets
|
mkdir -p /root/.electrum/wallets
|
||||||
|
|
||||||
|
# The allow-list may come from ALLOWED_USERS instead of a mounted file. Missing
|
||||||
|
# is fine; a *directory* here means Docker auto-created one for a bind mount
|
||||||
|
# that pointed at a non-existent path, which would silently lock everyone out.
|
||||||
|
ALLOWED_FILE="${ALLOWED_USERS_FILE:-/app/allowed_users.txt}"
|
||||||
|
if [ -d "$ALLOWED_FILE" ]; then
|
||||||
|
echo "ERROR: $ALLOWED_FILE is a directory."
|
||||||
|
echo "Create the file on the host before starting, or drop the bind mount"
|
||||||
|
echo "and set ALLOWED_USERS=<id>,<id> in your .env instead." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ALLOWED_FILE" ] && [ -z "${ALLOWED_USERS:-}" ]; then
|
||||||
|
echo "WARNING: no allow-list found — set ALLOWED_USERS or mount $ALLOWED_FILE" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
CONFIG="/root/.electrum/config"
|
CONFIG="/root/.electrum/config"
|
||||||
if [ ! -f "$CONFIG" ]; then
|
if [ ! -f "$CONFIG" ]; then
|
||||||
echo "Writing default config..."
|
echo "Writing default config..."
|
||||||
|
|||||||
+22
-1
@@ -3,9 +3,30 @@ set -e
|
|||||||
|
|
||||||
mkdir -p /root/.electrum/wallets
|
mkdir -p /root/.electrum/wallets
|
||||||
|
|
||||||
|
# The allow-list may come from ALLOWED_USERS instead of a mounted file. Missing
|
||||||
|
# is fine; a *directory* here means Docker auto-created one for a bind mount
|
||||||
|
# that pointed at a non-existent path, which would silently lock everyone out.
|
||||||
|
ALLOWED_FILE="${ALLOWED_USERS_FILE:-/app/allowed_users.txt}"
|
||||||
|
if [ -d "$ALLOWED_FILE" ]; then
|
||||||
|
echo "ERROR: $ALLOWED_FILE is a directory."
|
||||||
|
echo "Create the file on the host before starting, or drop the bind mount"
|
||||||
|
echo "and set ALLOWED_USERS=<id>,<id> in your .env instead." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ALLOWED_FILE" ] && [ -z "${ALLOWED_USERS:-}" ]; then
|
||||||
|
echo "WARNING: no allow-list found — set ALLOWED_USERS or mount $ALLOWED_FILE" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting Electrum daemon..."
|
echo "Starting Electrum daemon..."
|
||||||
rm -f /root/.electrum/daemon
|
rm -f /root/.electrum/daemon
|
||||||
electrum daemon -d
|
|
||||||
|
if [ -n "${ELECTRUM_SERVER:-}" ]; then
|
||||||
|
# Pin to one server (e.g. your own node) instead of random public peers
|
||||||
|
echo "Pinning Electrum to ${ELECTRUM_SERVER}"
|
||||||
|
electrum -1 -s "${ELECTRUM_SERVER}" daemon -d
|
||||||
|
else
|
||||||
|
electrum daemon -d
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Electrum daemon started. Launching bot..."
|
echo "Electrum daemon started. Launching bot..."
|
||||||
exec /opt/electrum/bin/python3 /app/bot.py
|
exec /opt/electrum/bin/python3 /app/bot.py
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ set -e
|
|||||||
|
|
||||||
mkdir -p /root/.electrum/wallets
|
mkdir -p /root/.electrum/wallets
|
||||||
|
|
||||||
|
# The allow-list may come from ALLOWED_USERS instead of a mounted file. Missing
|
||||||
|
# is fine; a *directory* here means Docker auto-created one for a bind mount
|
||||||
|
# that pointed at a non-existent path, which would silently lock everyone out.
|
||||||
|
ALLOWED_FILE="${ALLOWED_USERS_FILE:-/app/allowed_users.txt}"
|
||||||
|
if [ -d "$ALLOWED_FILE" ]; then
|
||||||
|
echo "ERROR: $ALLOWED_FILE is a directory."
|
||||||
|
echo "Create the file on the host before starting, or drop the bind mount"
|
||||||
|
echo "and set ALLOWED_USERS=<id>,<id> in your .env instead." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ALLOWED_FILE" ] && [ -z "${ALLOWED_USERS:-}" ]; then
|
||||||
|
echo "WARNING: no allow-list found — set ALLOWED_USERS or mount $ALLOWED_FILE" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting Tor..."
|
echo "Starting Tor..."
|
||||||
tor --RunAsDaemon 1 --SocksPort 9050 --DataDirectory /var/lib/tor
|
tor --RunAsDaemon 1 --SocksPort 9050 --DataDirectory /var/lib/tor
|
||||||
|
|
||||||
@@ -17,6 +31,12 @@ rm -f /root/.electrum/daemon
|
|||||||
|
|
||||||
PROXY="socks5:localhost:9050"
|
PROXY="socks5:localhost:9050"
|
||||||
|
|
||||||
|
# Everything the bot itself fetches must go through Tor too. Without these the
|
||||||
|
# mempool.space fee check would open a direct clear-net connection and tie this
|
||||||
|
# server's IP to its Bitcoin activity, defeating the point of Tor mode.
|
||||||
|
export TOR_SOCKS="127.0.0.1:9050"
|
||||||
|
export MEMPOOL_API_URL="${MEMPOOL_API_URL:-http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/fees/recommended}"
|
||||||
|
|
||||||
if [ -n "${ELECTRUM_SERVER:-}" ]; then
|
if [ -n "${ELECTRUM_SERVER:-}" ]; then
|
||||||
# Pin to a single user-specified server (own node)
|
# Pin to a single user-specified server (own node)
|
||||||
electrum -1 -s "${ELECTRUM_SERVER}" -p "${PROXY}" daemon -d
|
electrum -1 -s "${ELECTRUM_SERVER}" -p "${PROXY}" daemon -d
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
REGISTRY="git.goyban.com"
|
||||||
|
USER="goyban"
|
||||||
|
IMAGE="telegtrum"
|
||||||
|
|
||||||
|
# version from arg, else from the VERSION file, e.g. ./push.sh 1.0.1
|
||||||
|
VERSION="${1:-$(cat VERSION)}"
|
||||||
|
ELECTRUM_VERSION="${ELECTRUM_VERSION:-4.7.2}"
|
||||||
|
|
||||||
|
BASE="${REGISTRY}/${USER}/${IMAGE}"
|
||||||
|
|
||||||
|
echo "▶ Building ${BASE}:${VERSION} (Electrum ${ELECTRUM_VERSION}) ..."
|
||||||
|
docker build --build-arg "VERSION=${ELECTRUM_VERSION}" -t "${BASE}:${VERSION}" .
|
||||||
|
|
||||||
|
if [ "${VERSION}" != "latest" ]; then
|
||||||
|
echo "▶ Tagging as latest ..."
|
||||||
|
docker tag "${BASE}:${VERSION}" "${BASE}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▶ Pushing ${BASE}:${VERSION} ..."
|
||||||
|
docker push "${BASE}:${VERSION}"
|
||||||
|
|
||||||
|
if [ "${VERSION}" != "latest" ]; then
|
||||||
|
echo "▶ Pushing ${BASE}:latest ..."
|
||||||
|
docker push "${BASE}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Done: ${BASE}:${VERSION} + ${BASE}:latest"
|
||||||
|
echo
|
||||||
|
echo "To deploy: docker compose -f docker-compose.deploy.yml pull && \\"
|
||||||
|
echo " docker compose -f docker-compose.deploy.yml up -d"
|
||||||
Reference in New Issue
Block a user