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:
goyban
2026-09-01 16:34:26 +00:00
parent 468111a52a
commit b7a0a86be2
10 changed files with 615 additions and 438 deletions
+14
View File
@@ -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
View File
@@ -1,243 +1,226 @@
# ربات کیف‌پول آنلاین
# تله‌گتروم (Telegtrum)
یک ربات تلگرام خودمیزبان برای مدیریت کیف‌پول‌های بیت‌کوین با استفاده از Electrum. طراحی‌شده برای کیف‌پول‌های فقط-تماشا (xpub) با پشتیبانی از امضای آفلاین (air-gapped).
**تلگرام + الکترום** — یک ربات تلگرام خودمیزبان برای مدیریت کیف‌پول‌های بیت‌کوین با Electrum.
## قابلیت‌ها
تله‌گتروم برای **کیف‌پول‌های فقط-تماشا (xpub) با امضای آفلاین (air-gapped)** ساخته شده است. کلیدهای خصوصی شما هرگز روی سرور قرار نمی‌گیرند. ربات می‌تواند موجودی را ببیند، آدرس دریافت بسازد و تراکنش **امضانشده** بسازد؛ امضا فقط روی دستگاه آفلاین شما انجام می‌شود.
- کیف‌پول جداگانه برای هر کاربر
- بازیابی کیف‌پول فقط-تماشا از xpub
- نمایش موجودی (تأییدشده / تأییدنشده)
- دریافت آدرس
- ساخت تراکنش امضانشده (PSBT) با انتخاب کارمزد
- تغییر واحد: BTC / mBTC / ساتوشی
- پشتیبانی از زبان فارسی و انگلیسی
- کیبورد ثابت در پایین چت
- پشتیبانی اختیاری از Tor (اتصال از طریق .onion به سرور عمومی یا نود شخصی)
---
## مدل امنیتی — این بخش را حتماً بخوانید
| | |
|---|---|
| **سرور هیچ کلید خصوصی ندارد** | شما فقط **xpub** وارد می‌کنید. قابلیت تماشا دارد، نه خرج کردن. |
| **خرج کردن نیازمند دستگاه آفلاین است** | ربات تراکنش امضانشده می‌سازد؛ شما آن را جای دیگر امضا و برای انتشار برمی‌گردانید. |
| **دسترسی به‌صورت پیش‌فرض بسته است** | فقط شناسه‌های تلگرامی که شما تعیین کنید اجازه دارند. فهرست خالی یعنی هیچ‌کس اجازه ندارد. |
| **اطلاعات حساس از چت پاک می‌شوند** | رمز کیف‌پول و 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 نصب‌شده
- توکن ربات تلگرام (از [@BotFather](https://t.me/BotFather))
- شناسه کاربری تلگرام شما (از [@userinfobot](https://t.me/userinfobot))
- یک سرور لینوکسی با 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
cp .env.example .env && nano .env
docker compose up -d --build
```
این دستور:
- ایمیج Docker با Electrum نصب‌شده را می‌سازد
- دیمن Electrum را راه‌اندازی می‌کند
- ربات تلگرام را اجرا می‌کند
در این حالت فایل `bot.py` به‌صورت read-only مانت می‌شود، پس می‌توانید آن را ویرایش کنید و فقط `docker compose restart` بزنید.
### ۵. بررسی لاگ‌ها
### فهرست کاربران مجاز: فایل یا متغیر محیطی
```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
cp .env.tor.example .env
nano .env
docker compose -f docker-compose.tor.yml up -d --build
```
`TELEGRAM_BOT_TOKEN` را پر کنید و یکی از حالت‌های اتصال را انتخاب کنید:
**گزینه الف — نود Electrum شخصی از طریق Tor:**
```
یا نود onion خودتان را بدهید:
```dotenv
ELECTRUM_SERVER=yournode123.onion:50001:t
```
…یا خالی بگذارید تا از سرور onion عمومی استفاده شود (`electrums3lojbuj.onion:50001:t`، قابل تغییر با `ELECTRUM_ONION_SERVER`).
**گزینه ب — سرور عمومی .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.
نکته‌ها:
- Tor داخل همان کانتینر اجرا می‌شود؛ entrypoint قبل از اجرای Electrum منتظر آماده‌شدن پروکسی SOCKS می‌ماند.
- **استعلام کارمزد هم از Tor عبور می‌کند.** از سرویس onion سایت mempool.space استفاده می‌شود، بنابراین بررسی کارمزد نمی‌تواند IP سرور شما را به فعالیت بیت‌کوینی‌تان ربط دهد.
- اطلاعات کیف‌پول در ولوم جداگانه (`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` |
| **Add Wallet** | افزودن کیف‌پول فقط-تماشا: نام ← xpub ← رمز. پیام حاوی xpub پس از خواندن حذف می‌شود. |
| **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
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) |
## راهنمای استفاده از ربات
### شروع کار
ربات را در تلگرام باز کنید و هر پیامی ارسال کنید. یک کیبورد ثابت در پایین چت با تمام دکمه‌ها نمایش داده می‌شود.
### افزودن کیف‌پول (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** بزنید تا بین فارسی و انگلیسی تغییر دهید.
پشتیبان جای عبارت بازیابی (seed) را نمی‌گیرد. اینها کیف‌پول فقط-تماشا هستند؛ چیزی که واقعاً دارایی شما را برمی‌گرداند، seed روی دستگاه آفلاین شماست.
---
## عیب‌یابی
## رفع اشکال
**ربات پاسخ نمی‌دهد:** لاگ‌ها را با `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) را بخوانید، فقط ۶۰ خط است.
+148 -221
View File
@@ -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)
- 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)
---
## Security model — read this first
| | |
|---|---|
| **The server never holds private keys** | You import an **xpub**. It can watch, not spend. |
| **Spending needs your offline device** | The bot produces an unsigned transaction; you sign it elsewhere and paste it back to broadcast. |
| **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
- 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))
- A Linux server with Docker and Docker Compose
- A Telegram bot token — [@BotFather](https://t.me/BotFather)
- 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
git clone https://git.goyban.com/goyban/telegtrum_bot.git
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
cp .env.example .env
nano .env
### Allow-list: file or environment
Either works; if you use both, the two lists are merged.
**Environment** (simplest — nothing to mount):
```dotenv
ALLOWED_USERS=123456789,987654321
```
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
**File** — `allowed_users.txt`, one user per line, `id, Name`:
```bash
cp allowed_users.txt.example allowed_users.txt
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.
> 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
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
```bash
cp .env.example .env
nano .env
```
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
```dotenv
ELECTRUM_SERVER=192.168.1.100:50001:t
```
### Local / Umbrel — your own node
```bash
cp .env.example .env # set TELEGRAM_BOT_TOKEN and UMBREL_IP
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
{
"auto_connect": false,
"enable_proxy": false,
"oneserver": true,
"server": "192.168.1.100:50001:t"
}
```
> 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.
If the config file already exists it is left untouched, so manual edits survive restarts.
> **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
### Tor
```bash
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:
**Option A — your own Electrum server over Tor:**
```
Either point it at your own onion node:
```dotenv
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):**
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.
Notes:
- Tor runs inside the same container; the entrypoint waits for the SOCKS proxy before starting Electrum.
- **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.
- `:t` means TCP — Tor provides the encryption, so SSL is not needed.
---
## 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
# 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 |
| Button | What it does |
|---|---|
| Standard | `electrum-data` |
| Tor | `electrum-tor-data` |
| **Add Wallet** | Import a watch-only wallet: name → xpub → password. Your xpub message is deleted once read. |
| **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
docker run --rm -v electrum-data:/data -v $(pwd):/backup debian:bookworm-slim \
tar czf /backup/electrum-backup.tar.gz -C /data .
```
### Sending
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):
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).
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.
---
## 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.
+1
View File
@@ -0,0 +1 @@
1.0.0
+160 -29
View File
@@ -3,7 +3,9 @@ import io
import json
import logging
import os
import re
import subprocess
import tempfile
from decimal import Decimal, InvalidOperation
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update
@@ -27,9 +29,18 @@ logger = logging.getLogger(__name__)
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
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"
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 ──────────────────────────────────────────────────────────────
@@ -50,6 +61,11 @@ STRINGS: dict[str, dict[str, str]] = {
"Use the buttons below to get started."
),
"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.",
"cancel_for_button": "Previous operation cancelled. Tap the button again.",
"your_wallets": "Your wallets:",
@@ -146,6 +162,11 @@ STRINGS: dict[str, dict[str, str]] = {
"از دکمه‌های زیر شروع کنید."
),
"not_authorized": "شما مجاز به استفاده از این ربات نیستید.",
"invalid_wallet_name": ("❌ نام کیف‌پول نامعتبر است.\n\n"
"از ۱ تا ۶۴ کاراکتر استفاده کنید: حروف، اعداد، فاصله، نقطه، خط تیره یا زیرخط. "
"باید با حرف یا عدد شروع شود.\n\nدوباره تلاش کنید:"),
"wallet_exists": "❌ کیف‌پولی با نام '{name}' از قبل دارید. نام دیگری انتخاب کنید:",
"xpub_received": "✅ کلید عمومی اصلی دریافت شد — پیام شما برای حفظ حریم خصوصی حذف شد.",
"cancelled": "لغو شد.",
"cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.",
"your_wallets": "کیف پول‌های شما:",
@@ -248,9 +269,24 @@ def _load_settings() -> dict:
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)
# Written via a temp file + atomic rename: a crash half-way through can
# otherwise leave a truncated settings file, losing every user's wallet
# 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:
@@ -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]:
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:
with open(ALLOWED_USERS_FILE) as f:
for line in f:
@@ -397,8 +437,11 @@ def load_allowed_users() -> set[int]:
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)
except (FileNotFoundError, IsADirectoryError, PermissionError):
# 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
@@ -408,8 +451,32 @@ def user_wallet_path(user_id: int) -> str:
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]:
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]:
@@ -420,11 +487,26 @@ def run_electrum(*args: str) -> tuple[str, str, int]:
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
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"))
return False
return True
return False
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:
@@ -484,7 +566,10 @@ async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
return
unit = get_user_unit(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)
if rc != 0:
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:
if not await auth_check(update):
return
query = update.callback_query
await query.answer()
uid = query.from_user.id
@@ -509,7 +596,10 @@ async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
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}"
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)
if rc != 0:
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:
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}"
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)
msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout)
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:
if not await auth_check(update):
return
query = update.callback_query
await query.answer()
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:
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"))
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"))
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
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()
await _delete_quietly(update.message)
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))
wallet_path = wallet_file(uid, 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(
"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)
await context.bot.send_message(update.effective_chat.id, msg, reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
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:
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(
@@ -634,7 +752,7 @@ async def _wallet_selected(
async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["password"] = update.message.text.strip()
await update.message.delete()
await _delete_quietly(update.message)
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
op = context.user_data.get("op", "")
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 = {
"load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")),
"close": ("close_wallet", "-w", wallet_path),
@@ -711,10 +833,15 @@ async def fee_mempool(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
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,
)
cmd = ["curl", "-sSL", "--max-time", "15"]
if TOR_SOCKS:
# --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)
msg = t(uid, "fee_mempool_msg",
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:
context.user_data["password"] = update.message.text.strip()
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"))
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
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(
"payto",
"-w", wallet_path,
+31
View File
@@ -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:
+14
View File
@@ -5,6 +5,20 @@ set -e
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"
if [ ! -f "$CONFIG" ]; then
echo "Writing default config..."
+22 -1
View File
@@ -3,9 +3,30 @@ set -e
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..."
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..."
exec /opt/electrum/bin/python3 /app/bot.py
+20
View File
@@ -3,6 +3,20 @@ set -e
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..."
tor --RunAsDaemon 1 --SocksPort 9050 --DataDirectory /var/lib/tor
@@ -17,6 +31,12 @@ rm -f /root/.electrum/daemon
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
# Pin to a single user-specified server (own node)
electrum -1 -s "${ELECTRUM_SERVER}" -p "${PROXY}" daemon -d
Executable
+35
View File
@@ -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"