Microprogettazione — allineata al codice. La componente channels e' realizzata e testata a livello di modulo. Supporta multi-user: comando /start <token> per pairing dei guest, send_to(chat_id, OutboundMessage) e fallback multi-user su pairing senza riga in pairings.db. Da affiancare ad http_api: Telegram resta il canale primario per il dialogo; HTTP (porta 8770) e' il canale alternativo per dashboard host, automazioni curl/script e client Rust remoto. Riferimento di implementazione: /opt/metnos/runtime/channels/.
← Indice documentazione Microprogettazione › channel

Metnos

channel — come Metnos riceve e risponde dal mondo
Microprogettazione
Pubblico: chi vuole capire come si aggiunge un'interfaccia conversazionale.

Lettura: 10 minuti.

Indice

  1. Cosa fa: l'adattatore canale
  2. Il Protocol e i tipi
  3. Telegram come prima implementazione
  4. Long-poll e persistenza dell'offset
  5. Daemon: il loop poll → run_turn → send
  6. Bottoni inline e approval rendering
  7. Rapporto col pairing
  8. Distribuzione: systemd user unit
  9. HTTP come canale alternativo
  10. Limiti e rinvii a +

1. Cosa fa: l'adattatore canale

Un canale, in Metnos, è un adattatore: converte un'interfaccia esterna (CLI locale, bot Telegram, voce, in futuro Signal) in un flusso di messaggi parlato al runtime, e restituisce le risposte indietro. Niente di piu'. Tutta la logica conversazionale — pianificazione, esecuzione, vaglio — vive nel runtime; il canale è un tubo bidirezionale.

Telegram long-poll adapter canale send / poll agent runtime elabora il turno risposta send_to(chat_id)
Figura 1 — L'adattatore di canale: Telegram in long-poll, normalizzazione in un'interfaccia uniforme, instradamento al runtime e risposta per chat.

La scelta di trattare il canale come adattatore (e non come modulo distinto per ciascuna integrazione) è quella che permette di partire con due canali (CLI + Telegram) e aggiungerne altri senza toccare il nucleo. Vedi cap. 6 dell'Architettura: il canale sta nello strato 1 (Gateway), insieme all'autenticazione e al routing.

2. Il Protocol e i tipi

In runtime/channels/__init__.py il canale è un typing.Protocol con due metodi minimi e una proprieta' nome. Niente classe base, niente eredita': qualunque oggetto che esponga la forma giusta è un canale.

@runtime_checkable
class Channel(Protocol):
 name: str
 def send(self, recipient: str, message: OutboundMessage) -> dict:...
 def poll(self) -> list[InboundMessage]:...

I due tipi messaggio sono dataclass(frozen=True):

TipoCampiNote
InboundMessage channel, sender_id, text, message_id, received_at, extra Normalizzato: sender_id è sempre stringa anche dove il canale lo gestisce come int (Telegram chat_id). extra per metadata canale-specifici (es. from, update_id).
OutboundMessage text, reply_to, buttons buttons è list[list[dict]] (righe di bottoni) per i canali che la supportano. Su CLI viene ignorata.

La frozenness evita che il dispatcher modifichi un messaggio in arrivo: chi vuole arricchire crea un nuovo oggetto. È una scelta deliberata: un messaggio arrivato è un fatto storico, non un blocco di appunti.

3. Telegram come prima implementazione

runtime/channels/telegram.py implementa il protocol via Bot API. Niente libreria esterna: solo urllib + json. Coerente col principio self-host (cap. 4 Architettura): il bot interroga in uscita api.telegram.org, niente porte aperte, niente IP pubblico.

Configurazione (in ordine di precedenza):

  1. Parametri di costruzione: TelegramChannel(token=, default_chat_id=, credentials_path=, state_path=).
  2. Variabili d'ambiente: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.
  3. File ~/.config/metnos/credentials.env (chmod 600), formato dotenv.

Metodi:

4. Long-poll e persistenza dell'offset

getUpdates è chiamato in modalita' long-poll: il server Telegram tiene aperta la connessione fino a 25 secondi se non ci sono updates. Niente busy-loop, niente carico inutile, latenza minima quando un messaggio arriva.

L'offset (l'update_id dell'ultimo update visto) e' mantenuto in memoria. Per resistere ai riavvii (systemd restart, crash, deploy nuovo codice) viene anche scritto su disco in ~/.local/state/metnos/telegram_offset. Round-trip verificato in test (telegram_offset_persistito_round_trip); file corrotto torna a "offset = none" senza crash (telegram_offset_state_corrotto_torna_none).

La persistenza è opzionale: state_path=False la disabilita (utile in test). Senza persistenza, dopo un riavvio il bot rivede gli ultimi messaggi pendenti su Telegram e rischia la doppia risposta. Con persistenza, il primo poll dopo il riavvio parte da offset = ultimo_visto + 1 e Telegram non riconsegna i messaggi gia' acknowledged.

5. Daemon: il loop poll → run_turn → send

runtime/channels/daemon.py contiene ChannelDaemon: un singolo processo che cicla poll → per ogni messaggio: handle → (run_turn) → send. Niente threading. Le chiamate a run_turn sono sincrone. Latenza tipica per turno locale: 2-15 s.

Il daemon non «sa» che il canale è Telegram: lavora sul Channel Protocol. Quando arriva Signal, lo stesso daemon parte con un'istanza diversa.

Disciplina di errore (test daemon_run_turn_eccezione_non_crasha_loop):

Modi (CLI python3 -m channels.daemon):

6. Bottoni inline e approval rendering

OutboundMessage.buttons e' una matrice list[list[dict]]. Ogni dict ha text e data; data finisce nel callback_data del bottone Telegram.

La componente che produce le carte di approval e' in runtime/channels/approval.py: render_approval_card(ApprovalRequest) restituisce un OutboundMessage con la «carta a 3 righe» del dialog manager (vedi approval_ux). Tre forme modulate per ricorrenza:

Il dispatching dei callback (cliccare "Approva" risolve il token in una pending request e applica la decisione) e' gestito dal daemon tramite approval_registry.resolve (vedi approval_ux).

7. Rapporto col pairing

Il daemon non accetta messaggi da chiunque. Per ogni InboundMessage consulta pairing.get_pairing(channel, sender_id) (vedi pairing):

Il comando /pair <codice> sul canale e' intercettato prima del check di pairing — e' la sua ragione d'essere. Ogni messaggio di chi e' pairato aggiorna anche il last_seen via pairing.touch_last_seen, utile per audit e per rilevare account dormienti da revocare.

8. Distribuzione: systemd user unit

Il daemon gira come user unit, non come servizio di sistema: nessun root, nessun privilegio. Il file template e' in /opt/metnos/systemd/metnos-telegram-daemon.service. Hardening esplicito:

Restart=on-failure con backoff 5s. Il linger del user e' necessario perche' il servizio resti attivo senza login interattivo: loginctl enable-linger $USER (sudo richiesto la prima volta). Il README in /opt/metnos/systemd/README.md ha la procedura completa.

9. HTTP come canale alternativo

Dal Metnos espone un secondo server HTTP su porta 8770 con tre rotte agent-side: POST /agent/turn (esecuzione run_turn con SSE o JSON), GET /agent/devices/me, GET /agent/health e GET /.well-known/metnos.json. Vedi http_api per il dettaglio.

Il rapporto fra channel e http_api e' deliberato:

Multi-user: il comando /start <token> accettato dal daemon Telegram completa il pairing dei guest emesso dal pannello /admin/users dell'HTTP API. Il send_messages supporta to_user="lucia" e via_channel="auto": risolve via users.resolve_recipients, vaglio cross-user applicato per non-host.

10. Limiti e rinvii a +