From 025f46d399e965cb8cf13136f8408a2425328bb9 Mon Sep 17 00:00:00 2001 From: dsutanto Date: Thu, 6 Aug 2026 05:30:22 +0700 Subject: [PATCH] distribusi: notifikasi Telegram mulai/selesai/gagal (telegram.py, V30) --- .env.example | 7 +++++ AGENTS.md | 9 +++--- Dockerfile.dist | 2 +- SPEC.md | 6 +++- config.py | 9 ++++++ distribute.py | 12 +++++++- telegram.py | 56 ++++++++++++++++++++++++++++++++++ test_distribute.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 telegram.py diff --git a/.env.example b/.env.example index 3847a73..f4eb0ca 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,13 @@ SCAN_RUN_ON_START=1 DISTRIBUTE_HOUR=10 DISTRIBUTE_MAX_ATTEMPTS=3 +# — Notifikasi Telegram (distribusi mulai/selesai/gagal) — +# Token bot + daftar chat id tujuan (CSV; boleh id user atau grup). +# Kosong = notifikasi nonaktif. Di stack, isi di config.env NFS (dibaca +# config.py load_dotenv dari /app/.env). +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_IDS= + # Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan) MARIADB_ROOT_PASSWORD=root MARIADB_DATABASE=databisnisid diff --git a/AGENTS.md b/AGENTS.md index 018cc82..a2dae56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote `VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py` - Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan and dist images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. - Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans. -- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. -- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`. +- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. +- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`. - Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, and `./venv/bin/python test_distribute.py` must all exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. ## Key facts @@ -38,10 +38,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - `get_voters.py` — production fetcher: scan → filter (stake + freshness, basi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history). - `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`. - `get_voters.js` — reference implementation only. -- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan. `test_distribute.py` — oracle (mock chain+pyntelope, temp DB). +- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB). +- `telegram.py` — kirim notifikasi status distribusi via Telegram Bot API (best-effort; ⊥ token/chat id → no-op senyap; gagal kirim → log saja). `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_IDS` (CSV) dari env/`.env`/NFS `config.env`. - `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards. - `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd. -- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`; shared by get_voters & dashboard. +- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`; shared by get_voters & dashboard. - `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. - `distribute_loop.py` — scheduler harian dalam container stack (service `distribute`): tunggu `DISTRIBUTE_HOUR` (lokal via `TZ`), panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba besok; ⊥ distribusi-awal saat start (snapshot bisa basi). - `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). Di stack produksi ketiganya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context. diff --git a/Dockerfile.dist b/Dockerfile.dist index 7714224..205dc11 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -10,6 +10,6 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY config.py db.py distribute.py distribute_loop.py ./ +COPY config.py db.py distribute.py distribute_loop.py telegram.py ./ CMD ["python", "distribute_loop.py"] diff --git a/SPEC.md b/SPEC.md index 13b9a61..8e79bc9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -33,6 +33,7 @@ Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemili - retry dalam hari: ulang hanya baris `failed`; ⊥ kirim ulang `sent`; sebelum resend → verifikasi txid on-chain (Hyperion `get_transaction` → `executed`) - ⊥ freezing/carryover: tiap hari hitung ulang segar dari snapshot tersimpan; kegagalan sisa → ikut run besok - pemilih kosong | saldo < 1 unit shareable → no-op senyap (⊥ catat) +- notifikasi Telegram: bot token `TELEGRAM_BOT_TOKEN` + daftar chat id `TELEGRAM_CHAT_IDS` (CSV) — notifikasi mulai/selesai/gagal distribusi, best-effort (⊥ crash); tanpa token/daftar → no-op senyap; di stack nilai dari NFS `config.env` (`/app/.env`) - dashboard read-only + view riwayat `/history` (runs + per-voter log) - CPU/NET akun BP besar (limit 41.5M µs, NET 3.26M words) → ratusan transfer/hari sepele; Vexanium tanpa fee @@ -55,7 +56,8 @@ db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, t db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain) cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile -env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`) +env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS` (CSV) — via `config.py` (`.env`) +cmd: `./venv/bin/python telegram.py` — modul notifikasi (⊥ CLI); `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_IDS` kosong → no-op senyap ## §V — Invariants V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) @@ -86,6 +88,7 @@ V26: `VEX_BP_PRIVATE_KEY` ⊥ pernah di-log/print; `--dry-run` utk uji lokal ( V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer `vexascan.com/transaction/`; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker) +V30: notifikasi Telegram best-effort utk distribusi: `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial + terkirim/total/gagal), `notify_failure` saat exception di `main` (lalu re-raise); no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_IDS` → no-op senyap; gagal kirim → log warning, ⊥ pernah crash distribusi ## §T — Tasks id|status|task|cites @@ -121,6 +124,7 @@ T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom + T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web +T33|x|notifikasi Telegram: `telegram.py` (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_IDS`, Dockerfile.dist += telegram.py, oracle|V30,I.env ## §B — Bug log id|date|cause|fix diff --git a/config.py b/config.py index 94d6948..2e33a13 100644 --- a/config.py +++ b/config.py @@ -58,6 +58,15 @@ BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '') DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10')) DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3')) +# ————————————————— Notifikasi Telegram (V30) ————————————————— +# Bot token + daftar chat id (CSV) tujuan notifikasi mulai/selesai/gagal +# distribusi. Kosong → notifikasi nonaktif (no-op senyap). +TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '') +TELEGRAM_CHAT_IDS = [ + c.strip() for c in os.getenv('TELEGRAM_CHAT_IDS', '').split(',') + if c.strip() +] + # Kontrak token VEX = vex.token (⊥ eosio.token — tak ada di chain) TOKEN_CONTRACT = 'vex.token' VEX_SYMBOL = 'VEX' diff --git a/distribute.py b/distribute.py index 6c8675b..42e1f01 100644 --- a/distribute.py +++ b/distribute.py @@ -19,6 +19,7 @@ from decimal import Decimal, ROUND_FLOOR, InvalidOperation import requests import db +import telegram from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP, TOKEN_CONTRACT, VEX_SYMBOL) @@ -154,6 +155,9 @@ def run_distribution(dry_run=False): 'VEX_BP_PRIVATE_KEY kosong — set secret dulu sebelum run nyata ' '(⊥ distribusi tanpa key)') + total_share = sum(share for _, share in shares) + telegram.notify_start(run_date, len(shares), total_share, balance) + created_at = datetime.now().isoformat(timespec='seconds') total_stake = sum(float(s) for _, s in voters) run_id = db.record_run(run_date, float(balance), len(voters), total_stake, @@ -206,6 +210,8 @@ def run_distribution(dry_run=False): status = 'ok' if not failed else 'partial' db.update_run_status(run_id, status, round(sent_amount, 4)) + telegram.notify_finish(status, len(pending) - len(failed), len(pending), + total_share, failed, run_date) print(f'SELESAI: {len(pending) - len(failed)}/{len(pending)} terkirim ' f'(status run: {status})', flush=True) return 0 @@ -213,7 +219,11 @@ def run_distribution(dry_run=False): def main(): dry_run = '--dry-run' in sys.argv - return run_distribution(dry_run=dry_run) + try: + return run_distribution(dry_run=dry_run) + except Exception as exc: + telegram.notify_failure(exc) + raise if __name__ == '__main__': diff --git a/telegram.py b/telegram.py new file mode 100644 index 0000000..0902d10 --- /dev/null +++ b/telegram.py @@ -0,0 +1,56 @@ +"""Kirim notifikasi status distribusi via Telegram Bot API (best-effort). + +Token + daftar chat id dari env (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS` +CSV) via `config.py`. Tanpa token/daftar → no-op senyap (⊥ ganggu run lokal). +Gagal kirim hanya dicatat — ⊥ pernah crash distribusi. +""" + +import requests + +from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_IDS + + +def send_text(text): + """Kirim `text` ke semua chat id tujuan. Best-effort: gagal → log saja.""" + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_IDS: + return + url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage' + for chat_id in TELEGRAM_CHAT_IDS: + try: + resp = requests.post(url, json={'chat_id': chat_id, 'text': text}, + timeout=10) + resp.raise_for_status() + except requests.RequestException as exc: + print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}', + flush=True) + + +def notify_start(run_date, n_voters, total_share, balance): + """Notifikasi run nyata dimulai (setelah guard no-op + key siap).""" + send_text( + f'DISTRIBUSI MULAI\n' + f'Tanggal: {run_date}\n' + f'Pemilih: {n_voters}\n' + f'Total share: {total_share} VEX\n' + f'Saldo: {balance} VEX' + ) + + +def notify_finish(status, sent, total, total_share, failed, run_date): + """Notifikasi run selesai dengan ringkasan (ok | partial).""" + send_text( + f'DISTRIBUSI SELESAI\n' + f'Tanggal: {run_date}\n' + f'Status: {status}\n' + f'Terkirim: {sent}/{total} pemilih\n' + f'Total share: {total_share} VEX\n' + f'Gagal: {len(failed)}' + ) + + +def notify_failure(exc): + """Notifikasi run gagal total (exception di main).""" + send_text( + f'DISTRIBUSI GAGAL\n' + f'Error: {exc}' + ) diff --git a/test_distribute.py b/test_distribute.py index 3f7ed13..cbde763 100644 --- a/test_distribute.py +++ b/test_distribute.py @@ -17,6 +17,7 @@ from decimal import Decimal import db import distribute as dist +import telegram def check(tag, cond): @@ -68,6 +69,13 @@ def main(): db.DB_PATH = os.path.join(tmp, 'test.db') _orig_query = db.query + # Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30 + _tg_token = telegram.TELEGRAM_BOT_TOKEN + _tg_chats = telegram.TELEGRAM_CHAT_IDS + _tg_post = telegram.requests.post + telegram.TELEGRAM_BOT_TOKEN = '' + telegram.TELEGRAM_CHAT_IDS = [] + # ————— V20: fetch_balance — parse chain get_currency_balance ————— dist.requests.post = lambda url, json=None, timeout=30: fake_post( ['52186.0417 VEX']) @@ -215,6 +223,74 @@ def main(): check('V22 run status partial', calls['run_updates'][0][1] == 'partial') + # ————— V30: notifikasi Telegram — best-effort, ⊥ dry-run/no-op ————— + tg = {'posts': []} + + def tg_post(url, json=None, timeout=None): + tg['posts'].append((url, json['text'])) + return fake_post({}) + + telegram.TELEGRAM_BOT_TOKEN = '123:TEST' + telegram.TELEGRAM_CHAT_IDS = ['-100123'] + telegram.requests.post = tg_post + + # send_text tanpa token → no-op senyap (⊥ post) + telegram.TELEGRAM_BOT_TOKEN = '' + telegram.send_text('tidak boleh terkirim') + check('V30 ⊥ token → no-op senyap', tg['posts'] == []) + telegram.TELEGRAM_BOT_TOKEN = '123:TEST' + + # dry-run → ⊥ notif Telegram + make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00', + '2026-08-05T00:00:00'), + ('bbb2', '1.0', 700.0, '2026-08-05T00:00:00', + '2026-08-05T00:00:00')]) + db.query = _orig_query + dist.fetch_balance = lambda: Decimal('100.0000') + dist.BP_PRIVATE_KEY = '5Ktest' + dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txT' + to) + dist.DISTRIBUTE_MAX_ATTEMPTS = 3 + dist.verify_txid = lambda txid: True + calls['runs'] = [] + with redirect_stdout(io.StringIO()): + dist.run_distribution(dry_run=True) + check('V30 dry-run ⊥ notif', tg['posts'] == []) + + # run nyata → notif mulai + selesai + calls['updates'] = [] + calls['run_updates'] = [] + with redirect_stdout(io.StringIO()): + rc = dist.run_distribution(dry_run=False) + check('V30 run nyata exit 0', rc == 0) + check('V30 2 notif (mulai + selesai)', + len(tg['posts']) == 2) + check('V30 mulai tercatat', 'DISTRIBUSI MULAI' in tg['posts'][0][1]) + check('V30 selesai + status ok', 'DISTRIBUSI SELESAI' in tg['posts'][1][1] + and 'Status: ok' in tg['posts'][1][1]) + check('V30 ringkasan jumlah', '2/2 pemilih' in tg['posts'][1][1]) + + # no-op (pemilih kosong) → ⊥ notif baru + db.query = lambda sql, params=(): [] + with redirect_stdout(io.StringIO()): + dist.run_distribution(dry_run=False) + check('V30 no-op ⊥ notif', len(tg['posts']) == 2) + db.query = _orig_query + + # failure (⊥ key) → notif gagal lewat main(), lalu re-raise + dist.BP_PRIVATE_KEY = '' + try: + dist.main() + raise AssertionError('V30 gagal: main harusnya re-raise') + except RuntimeError: + check('V30 failure → re-raise RuntimeError', True) + check('V30 notif gagal terkirim', + len(tg['posts']) == 3 and 'DISTRIBUSI GAGAL' in tg['posts'][2][1]) + dist.BP_PRIVATE_KEY = '5Ktest' + + telegram.TELEGRAM_BOT_TOKEN = _tg_token + telegram.TELEGRAM_CHAT_IDS = _tg_chats + telegram.requests.post = _tg_post + print('\nSEMUA UJI PASS')