channel — come Metnos riceve e risponde dal mondoUn 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.
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.
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):
| Tipo | Campi | Note |
|---|---|---|
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.
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):
TelegramChannel(token=, default_chat_id=, credentials_path=, state_path=).TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.~/.config/metnos/credentials.env (chmod 600), formato dotenv.Metodi:
send(recipient, message) → POST sendMessage
verso api.telegram.org. Se buttons, costruisce
un inline_keyboard. Ritorna {ok, result?, error?, status_code?}.poll(timeout_s=25) → long-poll getUpdates
con offset interno; restituisce solo i message e
edited_message con campo text. I
callback_query (bottoni cliccati) sono gestiti dal
dispatcher dedicato (vedi cap. 6 e 9).
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.
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):
poll: log + sleep 5 s
+ riprova al tick successivo. Niente crash.run_turn: cattura,
risposta al sender con (errore interno:...), daemon prosegue.send: catturata da
TelegramChannel._call, ritorna dict {ok: False, error}.
Il turno e' gia' chiuso lato runtime, l'utente deve riprovare.Modi (CLI python3 -m channels.daemon):
--dry-run: logga le risposte ma non le invia. Utile per
osservare il comportamento del runtime senza effetti reali.--no-bootstrap: disabilita l'auto-pair del
default_chat_id (cap. 7). Serve per setup
multi-utente, dove anche Roberto si pair esplicitamente con un codice.-v: log a livello DEBUG.
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).
pairing
Il daemon non accetta messaggi da chiunque. Per ogni
InboundMessage consulta pairing.get_pairing(channel, sender_id)
(vedi pairing):
run_turn, salvo il caso autonomy_level == "ReadOnly"
in cui risponde con un messaggio cortese e non esegue azioni
(LEVEL_BLOCKS_RUN in daemon.py).Full (_try_bootstrap). Senza questo,
all'installazione Roberto dovrebbe generarsi un codice da solo —
passaggio inutile in dev.UNPAIRED_REPLY).
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.
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:
NoNewPrivileges=trueProtectSystem=strict + ReadWritePaths
limitati a ~/.local/state/metnos e ~/.local/share/metnosPrivateTmp=true, ProtectKernelTunables,
ProtectKernelModules, ProtectControlGroups,
RestrictNamespaces, RestrictRealtime,
LockPersonalityjournalctl --user -u metnos-telegram-daemon -f
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.
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:
run_turn riceve channel="http" e passa per gli stessi gate (vaglio, policy) di Telegram.channels e' incompatibile con request/response. POST /agent/turn chiama run_turn direttamente.
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.
approval_registry.resolve (vedi
approval_ux).poll
filtra esplicitamente i messaggi che non hanno text. Le
foto saranno trattate scaricando l'allegato in
workspace/inbox/ e passando il path al runtime; le note
vocali con whisper.cpp locale (cap. 4 Architettura,
principio open-source first).send.