# CryptoPost Bot

Bot Telegram per la vendita di post sponsorizzati su gruppi/canali, con pagamento
in criptovaluta tramite [NOWPayments](https://nowpayments.io), credito interno e
programma referral.

Riscrittura in **Node.js 22 + Docker** della precedente versione PHP.

---

## Indice

- [Avvio rapido](#avvio-rapido)
- [Configurazione](#configurazione)
- [Popolare tariffe e gruppi](#popolare-tariffe-e-gruppi)
- [Architettura](#architettura)
- [Come funziona il flusso di acquisto](#come-funziona-il-flusso-di-acquisto)
- [Comandi amministratore](#comandi-amministratore)
- [Test](#test)
- [Aggiornare un'installazione esistente](#aggiornare-uninstallazione-esistente)
- [Migrazione da CoinPayments a NOWPayments](#migrazione-da-coinpayments-a-nowpayments)
- [Migrazione dalla versione PHP](#migrazione-dalla-versione-php)
- [Cosa è cambiato rispetto al PHP](#cosa-è-cambiato-rispetto-al-php)
- [Operatività](#operatività)

---

## Avvio rapido

```bash
cp .env.example .env
```

Compila `.env` (vedi sotto), poi:

```bash
docker compose up -d --build
```

Lo stack avvia due container: `db` (MySQL 8.4, schema creato automaticamente al
primo avvio) e `app` (bot + server HTTP + worker di scadenza).

```bash
docker compose logs -f app
```

L'app espone `http://127.0.0.1:3000`. Davanti va messo un reverse proxy con TLS,
perché sia il webhook Telegram sia l'IPN di NOWPayments richiedono HTTPS.

### Senza dominio pubblico

Per lo sviluppo locale puoi usare il long polling, che non richiede HTTPS:

```bash
BOT_MODE=polling
```

L'endpoint IPN resta comunque necessario, e pubblico, per ricevere le conferme
di pagamento.

---

## Configurazione

Tutte le variabili sono documentate in [`.env.example`](.env.example). Le
obbligatorie:

| Variabile | Descrizione |
|---|---|
| `BOT_TOKEN` | token del bot, da [@BotFather](https://t.me/BotFather) |
| `BOT_USERNAME` | username del bot, usato nel link di invito |
| `BOT_ADMINS` | chat_id degli amministratori, separati da virgola — solo per il primo avvio, poi l'elenco vive in tabella (vedi [Amministratori](#amministratori)) |
| `DB_PASSWORD`, `DB_ROOT_PASSWORD` | password MySQL |
| `NP_API_KEY` | API key NOWPayments (Settings → Payments → API keys) |
| `NP_IPN_SECRET` | segreto IPN NOWPayments (mostrato per intero solo alla creazione) |
| `PUBLIC_URL` | URL pubblico HTTPS (obbligatorio con `BOT_MODE=webhook`) |
| `WEBHOOK_SECRET` | `openssl rand -hex 32` (obbligatorio con `BOT_MODE=webhook`) |

Se manca qualcosa l'app **non parte** e stampa l'elenco delle variabili
mancanti, invece di funzionare a metà.

Parametri economici regolabili senza toccare il codice: `PAYOUT_MIN_USD`
(soglia minima di prelievo), `REFERRAL_PERCENT` (provvigione), `WALLET_MIN_LENGTH`,
`PAYOUT_CURRENCY`, `EXPIRY_INTERVAL_SECONDS`.

### Impostazioni lato NOWPayments

1. **Coins settings**: abilita le monete che vuoi accettare. Devono coincidere
   con `NP_COINS`, altrimenti la creazione del pagamento fallisce e l'utente
   torna alla scelta della criptovaluta.
2. **Instant Payment Notifications**: genera il segreto IPN e mettilo in
   `NP_IPN_SECRET`. L'URL dei callback non va impostato nel pannello: lo manda
   il bot a ogni pagamento (`https://tuo-dominio/ipn`).
3. **Prelievi (opzionali)**: i payout richiedono un token JWT oltre alla API
   key, quindi servono anche `NP_EMAIL` e `NP_PASSWORD` (le credenziali del
   dashboard, *case-sensitive*).
   - Attiva la 2FA da app e metti il segreto base32 in `NP_2FA_SECRET`: senza,
     ogni payout resta in stato `creating` e va confermato a mano dal pannello
     **entro un'ora**, altrimenti viene rifiutato.
   - In *Settings → Whitelist* NOWPayments limita per impostazione predefinita
     i payout agli indirizzi in whitelist e alle richieste da IP autorizzati.
     Il bot paga verso wallet arbitrari degli utenti: disattiva la whitelist
     degli indirizzi e autorizza l'IP pubblico del server, altrimenti ogni
     prelievo verrà respinto (il credito viene comunque restituito).

---

## Popolare tariffe e gruppi

Lo schema crea cinque tariffe di esempio e nessun gruppo. **Finché `gruppi` è
vuota il flusso di acquisto si ferma subito dopo la scelta della tariffa**, con
il messaggio "Nessun gruppo è disponibile in questo momento".

Il modo previsto per riempirle è dalla chat del bot, con i comandi
amministratore (vedi [Comandi amministratore](#comandi-amministratore)):

```
/gruppo_add @miogruppo
/tariffa_add 6 ore 2.50
```

Il bot deve **già** essere amministratore del gruppo, con i permessi di inviare
e cancellare messaggi: `/gruppo_add` lo verifica e rifiuta l'inserimento
altrimenti, così il problema non salta fuori al primo post pagato.

In alternativa si può scrivere direttamente in database:

```bash
docker compose exec db mysql -ucryptopost -p cryptopost
```

```sql
-- gruppi su cui pubblicare
INSERT INTO gruppi (chat_id, link, titolo)
  VALUES (-1001234567890, 'https://t.me/miogruppo', 'Mio Gruppo');

-- tariffe: `descrizione` va scritta in italiano canonico
-- (ore / giorno / giorni / mese / mesi / anno), la traduzione è automatica
INSERT INTO tariffe (descrizione, prezzo, tempo_s) VALUES ('6 ore', 5.00, 21600);
```

Scrivendo a mano, però, `descrizione` e `tempo_s` possono divergere: una tariffa
descritta come "1 mese" con `tempo_s` da un'ora scade dopo un'ora. `/tariffa_add`
deriva entrambi i campi dallo stesso input e non ha questo problema.

---

## Architettura

Un solo processo Node contiene tutto: webhook, IPN, invii globali e worker di
scadenza. La versione PHP richiedeva invece tre processi separati (`bot.php` via
webserver, `loop.php` e `msg_global.php` lanciati con `screen`).

```
src/
  index.js              avvio, registrazione webhook, spegnimento pulito
  config.js             configurazione validata all'avvio
  server.js             Fastify: /health, webhook Telegram, /ipn NOWPayments
  db.js                 pool MySQL, transazioni, query parametrizzate
  telegram.js           client Bot API (POST JSON, retry, timeout)
  nowpayments.js        client API (api key + JWT), firma IPN, stima di cambio
  i18n.js               dizionari it/eng, traduzione delle durate
  i18n/{it,eng}.json
  bot/
    index.js            dispatcher: carica l'utente, serializza, instrada
    handlers.js         passi del flusso conversazionale
    admin.js            comandi amministratore
    keyboards.js        tastiere
    states.js           stati della macchina a stati
  services/
    users.js            utenti, stato, bilancio (addebiti atomici)
    admins.js           elenco amministratori su database, con cache
    support.js          ticket di assistenza e inoltro fra utente e admin
    catalog.js          tariffe e gruppi, risoluzione delle etichette
    drafts.js           bozza del post in attesa di pagamento
    orders.js           ordini, pubblicazione, referral, scadenze
    fulfillment.js      ordine pagato -> post pubblicato (idempotente)
    payouts.js          prelievi con rollback del credito
    broadcast.js        invio globale con throttling
  workers/
    expiry.js           rimozione dei post scaduti
    polling.js          long polling (alternativa al webhook)
  lib/
    mutex.js            serializzazione per utente
    qr.js               URI di pagamento + QR generato in memoria
    totp.js             codici 2FA per la conferma dei payout
db/
  init/01-schema.sql    schema, applicato al primo avvio del volume
  migrate-add-supporto.sql
                        amministratori e ticket su un database già avviato
  migrate-from-php.sql  migrazione di un database esistente
  migrate-coinpayments-to-nowpayments.sql
                        rinomina `prelievi.cp_id` in `np_id`
test/
  unit.test.mjs         moduli puri
  e2e.test.mjs          flussi completi su MySQL reale
  run-e2e.sh
```

### Endpoint HTTP

| Metodo | Path | Descrizione |
|---|---|---|
| `GET` | `/health` | `200` se il database risponde, `503` altrimenti |
| `POST` | `$WEBHOOK_PATH` | update Telegram; richiede l'header segreto |
| `POST` | `/ipn` | notifiche NOWPayments; richiede `x-nowpayments-sig` valido |

---

## Come funziona il flusso di acquisto

L'utente sceglie **tariffa → gruppo → contenuto → pagamento**. Il passo corrente
è salvato in `utenti.updatew`.

Due varianti:

- **Pagamento in criptovaluta**: si crea una fattura NOWPayments (per il link
  di checkout) e il relativo pagamento (per indirizzo e importo). L'utente
  riceve indirizzo, importo, QR code e — dove serve, come su XRP o XLM — il
  memo/tag da includere. Alla conferma del pagamento (IPN con
  `payment_status` fra quelli elencati in `NP_PAID_STATUSES`, di default
  `finished`) il post viene pubblicato.

  L'ordine da evadere viaggia nell'`order_id` (`<chat_id>:<codice>`), che fa
  parte del payload firmato: NOWPayments firma il corpo del callback e non
  l'URL, quindi leggere l'ordine dalla query string permetterebbe di rigiocare
  un IPN valido su un post diverso.
- **Pagamento con credito**: il credito accumulato tramite referral viene
  scalato subito e il post pubblicato immediatamente.

Ogni post pubblicato viene rimosso dal gruppo allo scadere della durata della
tariffa, dal worker di scadenza.

Quando un utente invitato tramite `https://t.me/<bot>?start=<tuo_id>` compra un
post, `REFERRAL_PERCENT` del prezzo finisce sul credito di chi l'ha invitato.

---

## Comandi amministratore

Riservati a chi è nella tabella `amministratori`. `/admin` stampa questo elenco
in chat.

### Amministratori

| Comando | Uso |
|---|---|
| `/admins` | elenco con chat_id, nome e username |
| `/admin_add <chat_id>` | nomina; accetta anche la risposta a un messaggio inoltrato |
| `/admin_del <chat_id>` | revoca |

L'elenco sta sul database, non nel `.env`: nominare qualcuno non richiede più di
modificare la configurazione e riavviare il container. `BOT_ADMINS` resta come
seme e viene applicato **solo se la tabella è vuota**, cioè al primo avvio —
altrimenti chi è stato revocato con `/admin_del` tornerebbe amministratore al
riavvio successivo, visto che il suo chat_id è ancora nel file.

L'ultimo amministratore non si può revocare: a tabella vuota nessuno potrebbe
più usare i comandi, e per rientrare servirebbe un accesso al database.

Chi viene nominato riceve un messaggio dal bot. Per conoscere il proprio
chat_id basta che scriva `/chat_id` al bot in privato.

### Assistenza

Nel menu principale c'è **Supporto 🆘**: l'utente scrive il problema (anche con
una foto) e il messaggio arriva a tutti gli amministratori. La conversazione
resta aperta finché non viene chiusa, e ogni messaggio è archiviato.

| Comando | Uso |
|---|---|
| `/ticket` | richieste aperte, con quante persone hanno scritto e chi ha parlato per ultimo |
| `/ticket <id>` | conversazione completa: per ogni risposta si vede **quale amministratore** l'ha scritta |
| `/rispondi <id> <testo>` | risponde all'utente del ticket |
| `/ticket_chiudi <id>` | chiude il ticket e avvisa l'utente |

Il modo più rapido di rispondere è la funzione **rispondi** di Telegram sul
messaggio del ticket: il testo arriva all'utente, viene registrato a nome di chi
l'ha scritto e gli **altri** amministratori ricevono una copia con il nome di
chi ha risposto — così due persone non rispondono alla stessa domanda senza
saperlo.

Il ticket è ritrovato tramite il riferimento `#T<id>` che il bot stampa in
intestazione, non tramite `forward_from` come `/ban` e `/messaggio`: quel campo
sparisce se l'utente nasconde l'account nei messaggi inoltrati.

Un solo ticket aperto per utente, garantito da un indice univoco: due messaggi
inviati insieme finiscono nella stessa conversazione invece di aprirne due.

### Utenti

| Comando | Uso |
|---|---|
| `/ban` | in risposta a un messaggio inoltrato dell'utente |
| `/unban` | come sopra |
| `/messaggio <testo>` | in risposta a un messaggio inoltrato |
| `/messaggioglobale <testo>` | invio a tutti gli utenti non bannati |
| `/stats` | utenti, ticket aperti, post attivi, incassato |

L'invio globale procede in background con throttling e riporta un riepilogo a
fine corsa. Gli utenti che hanno bloccato il bot vengono marcati automaticamente
per non essere ricontattati.

### Tariffe

| Comando | Uso |
|---|---|
| `/tariffe` | elenco con gli id |
| `/tariffa_add <durata> <prezzo>` | es. `/tariffa_add 6 ore 2.50` |
| `/tariffa_prezzo <id> <prezzo>` | es. `/tariffa_prezzo 3 15` |
| `/tariffa_del <id>` | elimina la tariffa |

La durata si scrive come `<numero> <unità>` in italiano — `ore`, `giorni`,
`settimane`, `mesi`, `anni` — e da lì vengono derivati sia `descrizione` sia
`tempo_s`. Il mese vale 30 giorni, l'anno 365. Singolare e plurale sono
normalizzati sul numero: `1 giorni` viene salvato come `1 giorno`, altrimenti
la traduzione inglese produrrebbe "1 days".

Cambiare un prezzo non tocca gli ordini già fatti: `history.prezzo` è una copia
del prezzo al momento dell'acquisto.

Il listino nel messaggio di `/start` **non è testo fisso**: la traduzione
`welcome` contiene il segnaposto `{tariffe}`, che `welcomeText()` riempie
leggendo la tabella. Aggiungere o ritoccare una tariffa aggiorna insieme il
menu e il messaggio di benvenuto, che quindi non possono più divergere. Le
durate dal giorno in su vengono esplicitate come nel listino originale
(`1 mese (30 giorni)`), calcolando il valore fra parentesi da `tempo_s`.

### Gruppi

| Comando | Uso |
|---|---|
| `/gruppi` | elenco con gli id |
| `/gruppo_add <@username \| link \| chat_id>` | es. `/gruppo_add @miogruppo` |
| `/gruppo_del <id>` | elimina il gruppo |
| `/chat_id` | **scritto dentro il gruppo**, ne mostra il chat_id |

`/chat_id` è l'unico comando che il bot elabora fuori dalla chat privata, e
solo se arriva da un `BOT_ADMINS` (per chiunque altro non risponde). Serve per
i gruppi **privati**, il cui chat_id non è ricavabile in altro modo: il link
d'invito `t.me/+hash` non è risolvibile da `getChat`. La risposta contiene già
il comando `/gruppo_add <chat_id>` pronto da copiare.

Non funziona nei **canali**: lì i messaggi arrivano come `channel_post`, che
non è fra gli `allowed_updates`, e sono anonimi — non ci sarebbe modo di
verificare che a scrivere sia un amministratore. Per un canale privato serve
il chat_id da Telegram Web.

`/gruppo_add` risolve lo username o il link con `getChat`, verifica che il bot
sia amministratore e avvisa se non può cancellare messaggi (i post verrebbero
pubblicati ma non rimossi alla scadenza). Per un gruppo **privato** il link
d'invito `t.me/+hash` non è risolvibile via API: serve il `chat_id` numerico.
Rieseguire il comando su un gruppo già presente ne aggiorna link e titolo.

### Cancellazioni bloccate

`/tariffa_del` e `/gruppo_del` rifiutano di procedere se esistono post ancora
aperti (`verify` 0 o 1) che li referenziano, e dicono quanti sono.

Non è una cautela formale: `findExpiredOrders` e `getOrder` fanno JOIN su
`tariffe` e `gruppi`. Cancellando una riga referenziata, i post in attesa di
pagamento non verrebbero mai pubblicati e quelli già pubblicati non
scadrebbero mai — resterebbero sul gruppo per sempre.

---

## Test

```bash
npm test        # moduli puri: nessun database, nessuna rete
npm run test:e2e   # flussi completi (richiede Docker)
```

`test:e2e` avvia un MySQL usa-e-getta, applica lo schema del progetto e percorre
i due flussi di acquisto, l'IPN (firma valida, invalida, duplicata), il
referral, i prelievi (riusciti e falliti), i comandi admin, la gestione degli
amministratori su database, i ticket di assistenza (apertura, risposta,
chiusura), la scadenza dei post e gli endpoint HTTP — mockando Telegram e
NOWPayments.

---

## Aggiornare un'installazione esistente

Gli script in `db/init/` girano **solo alla primissima creazione del volume**
MySQL: su un database già avviato le tabelle nuove vanno aggiunte a mano.

Per amministratori su database e assistenza:

```bash
docker compose exec -T db mysql -u root -p"$DB_ROOT_PASSWORD" cryptopost < db/migrate-add-supporto.sql
```

Poi riavvia l'app. Al primo avvio con la tabella `amministratori` vuota, il bot
ci travasa i chat_id di `BOT_ADMINS`; da quel momento l'elenco si gestisce con
`/admins`, `/admin_add` e `/admin_del`.

Fai un backup prima:

```bash
docker compose exec -T db mysqldump -u root -p"$DB_ROOT_PASSWORD" cryptopost > backup-$(date +%F).sql
```

---

## Migrazione da CoinPayments a NOWPayments

Il bot usava CoinPayments; ora usa NOWPayments. Su un'installazione nuova non
serve fare nulla. Su una già in esercizio:

1. **Ordini in attesa**: gli ordini pagabili creati con CoinPayments non
   riceveranno più IPN. Attendi che si esauriscano, oppure pubblicali a mano
   dopo aver verificato l'incasso sul vecchio pannello.
2. **Database**: applica la migrazione, che rinomina `prelievi.cp_id` in
   `np_id`. Nessun dato viene perso.
   ```bash
   mysql -u root -p cryptopost < db/migrate-coinpayments-to-nowpayments.sql
   ```
   `history.txn_id` non cambia forma: prima conteneva il `txn_id` CoinPayments,
   ora il `payment_id` NOWPayments.
3. **Variabili d'ambiente**: sostituisci le `CP_*` con le `NP_*` descritte in
   [`.env.example`](.env.example). Spariscono anche `PRICE_API_URL` e
   `PRICE_CACHE_MS`: il tasso di cambio non arriva più da Binance ma dalla
   stessa NOWPayments che poi esegue il pagamento, e si regola con
   `RATE_CACHE_MS`.
4. Completa le [impostazioni lato NOWPayments](#impostazioni-lato-nowpayments)
   e riavvia lo stack.

Cosa cambia nel comportamento:

| | CoinPayments | NOWPayments |
|---|---|---|
| Autenticazione | HMAC-SHA512 sul body di ogni chiamata | `x-api-key`, più un JWT per i payout |
| Firma IPN | header `HMAC` sul corpo grezzo | header `x-nowpayments-sig` sul JSON con le chiavi ordinate |
| Ordine da evadere | dalla query string dell'`ipn_url` | dall'`order_id`, dentro il payload firmato |
| Pagato quando | `status >= 100` | `payment_status` in `NP_PAID_STATUSES` |
| QR code | scaricato da `qrcode_url` | generato in locale (l'indirizzo non esce dal server) |
| Prelievi | `create_withdrawal` | `POST /payout` + conferma 2FA |
| Quotazione | Binance | `GET /estimate` di NOWPayments |

---

## Migrazione dalla versione PHP

I nomi di tabelle e colonne sono rimasti gli stessi, così come i valori di
`updatew`: gli utenti a metà flusso non si accorgono del cambio.

1. **Backup**:
   ```bash
   mysqldump -u root -p cryptopost > backup-$(date +%F).sql
   ```
2. Applica [`db/migrate-from-php.sql`](db/migrate-from-php.sql), che corregge i
   tipi (`ban` da `'yes'`/`''` a booleano, `balance` a `DECIMAL`), aggiunge gli
   indici e chiude i post già scaduti.
3. Compila `.env` e avvia lo stack.
4. Rimuovi il webhook della vecchia installazione: la nuova app registra il
   proprio all'avvio, e due bot sullo stesso token si contendono gli update.
5. Ferma i processi `screen` di `loop.php` e `msg_global.php`: le loro funzioni
   sono ora dentro l'app.

> **Ruota le credenziali.** Il codice PHP conteneva in chiaro token del bot,
> chiavi API del gateway, password MySQL e segreto IPN. Vanno considerate
> compromesse: rigenerale tutte prima di andare in produzione.

I sorgenti PHP sono stati rimossi: la conversione è completa e nessun file di
questo progetto ne dipende. Restano da ripulire, se non ti servono più, i QR
code generati dalla vecchia versione (`qrcode/`, immagini di ordini conclusi —
il nuovo codice non scrive su disco) e la configurazione PhpStorm in `.idea/`.

Se il vecchio bot gira ancora su un server remoto, ricordati di rimuovere lì i
file e i processi `screen`: la cancellazione locale non lo tocca.

---

## Cosa è cambiato rispetto al PHP

Oltre al cambio di linguaggio, la riscrittura corregge alcuni problemi presenti
nell'originale.

### Sicurezza

| | Prima | Ora |
|---|---|---|
| SQL | valori interpolati nelle query: qualunque messaggio con un apostrofo era una SQL injection | query parametrizzate |
| Segreti | token, chiavi API, password e segreto IPN in chiaro nei sorgenti | variabili d'ambiente |
| Invio globale | `shell_exec("screen -d -m php msg_global.php ".$messaggio)`: un backtick nel testo eseguiva comandi sul server | invio in-process, nessuna shell |
| File di lingua | `include('language/'.$language.'.php')` con valore dal database: path traversal | dizionari JSON caricati all'avvio |
| Webhook | nessuna verifica: chiunque conoscesse l'URL poteva inviare update falsi | header segreto verificato |
| HMAC IPN | confronto con `!=` | `timingSafeEqual` |
| TLS verso il gateway di pagamento | `CURLOPT_SSL_VERIFYPEER = 0` | verifica attiva |
| Codici ordine | `rand()`: prevedibili, e il codice autorizza la pubblicazione | `crypto.randomBytes` |
| Comandi admin | `/ban` senza reply eseguiva `WHERE chat_id = ''` | bersaglio obbligatorio |

### Correttezza

- **IPN duplicati**: il gateway manda una notifica per ogni cambio di stato,
  più i rinvii in caso di errore, e il PHP ripubblicava il post ogni volta.
  Ora un `UPDATE` condizionale fa da lock.
- **Pagamento con credito**: leggeva il saldo, confrontava in PHP e riscriveva.
  Due messaggi ravvicinati spendevano lo stesso credito due volte. Ora
  l'addebito e la creazione dell'ordine stanno in una transazione con vincolo
  `balance >= prezzo`.
- **Prelievi**: il PHP chiamava l'API senza controllare il risultato e poi
  azzerava l'intero bilancio. Un prelievo fallito perdeva i soldi dell'utente,
  e una provvigione arrivata nel frattempo spariva. Ora l'addebito precede la
  chiamata, ed è annullato se la chiamata fallisce.
- **Wallet BTC**: accettava qualunque stringa di 26+ caratteri. Ora è validato.
- **Quotazione BTC**: nessun timeout né controllo; se l'API del prezzo
  rispondeva male l'importo del prelievo diventava `Infinity`. Ora arriva da
  NOWPayments — la stessa fonte che esegue il pagamento — validata e in cache.
- **Scadenza post**: l'ordine veniva creato con `time_start = 0`, quindi il post
  risultava scaduto da sempre e il loop tentava di cancellarlo per sempre.
  `time_start` viene scritto alla pubblicazione e l'ordine chiuso dopo la
  rimozione.
- **Tariffe tradotte**: il menu inglese mostrava "3 days" ma la selezione
  ricercava "3 giorni" nel database, rimappando solo la parola "ore": tutte le
  tariffe in giorni o mesi risultavano inesistenti per gli utenti inglesi. Ora
  la corrispondenza avviene sulle etichette generate.
- **Lingua**: `language_code` di Telegram è `en`, non `eng`, quindi
  `include('language/en.php')` falliva e le etichette risultavano vuote. Ora i
  codici sono normalizzati.
- **Testo dei messaggi**: veniva codificato solo se conteneva un ritorno a capo,
  quindi i post con `&` o `#` arrivavano troncati. Ora le chiamate sono POST con
  corpo JSON.
- **Post con HTML non valido**: veniva rifiutato da Telegram e perso, benché
  pagato. Ora si ripubblica come testo semplice.
- **Collisioni fra pulsanti e contenuto**: gli `if` del PHP venivano valutati
  tutti in sequenza, così un post contenente il testo di un pulsante attivava
  più rami. Ora la catena si ferma al primo handler e durante l'inserimento del
  contenuto i pulsanti non intercettano il testo.

### Prestazioni e operatività

- **Query**: `loop.php` eseguiva due query annidate per ogni post attivo a ogni
  giro; l'utente veniva letto con tre round-trip per messaggio. Ora `JOIN`,
  upsert e indici mirati.
- **Connessioni**: ogni metodo del `DataBase` PHP apriva una nuova connessione
  MySQL (oltre dieci per messaggio). Ora un pool.
- **QR code**: salvato su disco in `qrcode/`, servito pubblicamente e mai
  ripulito. Ora passa in memoria direttamente a Telegram.
- **Rate limit**: `sleep(0.2)` in PHP equivale a `sleep(0)`, quindi l'invio
  globale superava i limiti di Telegram e metà dei messaggi venivano scartati.
  Ora c'è throttling configurabile e ritentativi su `429`.
- **Errori**: `catch (Exception $e) {}` silenziosi. Ora log strutturati JSON.
- **Spegnimento**: `SIGTERM` interrompeva il processo a metà operazione. Ora
  l'arresto è ordinato.
- **Processi**: da tre (webserver + due `screen`) a un container.

---

## Operatività

```bash
docker compose logs -f app          # log
docker compose restart app          # riavvio
docker compose up -d --build        # aggiornamento dopo modifiche
curl -s localhost:3000/health       # stato
npm run webhook:info                # stato del webhook (serve un .env locale)
```

Backup del database:

```bash
docker compose exec db mysqldump -ucryptopost -p cryptopost > backup-$(date +%F).sql
```

I log sono JSON su stdout, con rotazione gestita da Docker (10 MB × 5 file).
`LOG_LEVEL=debug` aumenta il dettaglio.
