channel — come Metnos riceve e risponde dal mondoI canali traducono browser, API HTTP e Telegram nello stesso contratto di messaggi del runtime. Identità, livello di autonomia, stato del dialogo e destinazione della risposta restano associati all'utente e al canale autenticati: un messaggio non eredita il contesto di un'altra persona.
Chiedi a Metnos con una richiesta come quella di questo esempio:
«Invia a Lucia su Telegram il riepilogo dell'incontro di oggi».
Metnos risolve il nome attraverso i canali associati all'utente: non occorre
conoscere o inserire il chat_id di Telegram. L'invio resta soggetto
ai controlli previsti per chi lo richiede e per il destinatario.
Un canale collega un'interfaccia conversazionale al runtime. La chat web invoca direttamente l'API HTTP; Telegram usa un adattatore con long polling. In entrambi i casi, il trasporto consegna testo, allegati e metadati autenticati, mentre il runtime gestisce pianificazione, esecuzione, dialoghi e controlli di sicurezza.
Il contratto separa il trasporto dalla logica dell'agente. Il canale Telegram normalizza gli aggiornamenti in ingresso e le risposte in uscita; il server HTTP espone lo stesso runtime con un modello richiesta/risposta. In entrambi i casi l'identità viene risolta prima di elaborare il contenuto.
Protocol e i tipi
In runtime/channels/__init__.py il canale è un
typing.Protocol con due metodi minimi e una proprietà name.
Non servono una classe base o l'ereditarietà: 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 una stringa, anche dove il trasporto usa un intero (il chat_id di Telegram). extra contiene i metadati specifici del canale, per esempio from e update_id. |
OutboundMessage |
text, reply_to, buttons |
buttons è list[list[dict]] (righe di pulsanti) per i trasporti che supportano azioni interattive. |
L'immutabilità (oggetto frozen) 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.telegram_bot_token e telegram_chat_id_host.~/.config/metnos/credentials.env (permessi 600), usato solo come ripiego compatibile.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 polling con
getUpdates e offset interno; normalizza messaggi di testo, foto,
posizione, callback_query e aggiornamenti senza contenuto
azionabile. I pulsanti premuti vengono inoltrati al dispatcher descritto nel
cap. 6.
getUpdates è chiamato in modalità long polling: il
server Telegram tiene aperta la connessione fino a 25 secondi se non
ci sono updates. Niente ciclo a vuoto, niente carico inutile, latenza minima
quando un messaggio arriva.
L'offset, cioè l'update_id dell'ultimo aggiornamento
confermato, viene mantenuto in memoria e scritto su disco in
~/.local/state/metnos/telegram_offset. La scrittura è
atomica. Se il file è illeggibile o danneggiato, il canale riparte senza
offset invece di interrompersi.
La persistenza è opzionale: state_path=False la
disabilita, principalmente nei test. Il daemon conferma un aggiornamento
tramite ack() soltanto dopo averlo gestito. Se la gestione o la
scrittura dell'offset fallisce, quell'aggiornamento non viene confermato e
può essere riconsegnato al ciclo successivo.
runtime/channels/daemon.py contiene ChannelDaemon:
un processo che esegue in sequenza
poll → per ogni messaggio: handle → run_turn → send.
Le chiamate a run_turn sono sincrone. Lo stesso processo può
avviare, in un thread separato, il listener HTTP destinato agli executor
remoti; questo non rende concorrente l'elaborazione dei messaggi Telegram.
ChannelDaemon lavora sul Channel Protocol; le parti
specifiche di Telegram, come download degli allegati e risposta ai callback,
restano nell'adattatore o nei relativi dispatcher.
Disciplina di errore:
poll: log + sleep 5 s
+ riprova al tick successivo. Niente crash.send: catturata da
TelegramChannel._call, ritorna dict {ok: False, error}.
Il fallimento viene registrato e il daemon applica i percorsi di consegna di
ripiego previsti per la risposta.Opzioni del comando ./.venv/bin/python -m runtime.channels.daemon,
eseguito dalla radice del repository:
--channel telegram: seleziona l'adattatore; Telegram è
l'unico valore disponibile.--dry-run: elabora il turno e registra la risposta, ma non
invia la risposta finale sul canale. Non è una sandbox: run_turn
viene comunque eseguito e gli executor possono produrre effetti.--no-bootstrap: disabilita l'associazione automatica del
default_chat_id (cap. 7), così anche l'utente host deve
completare un'associazione esplicita.--no-agent-server: non avvia il listener HTTP per gli executor
remoti. Senza questa opzione il listener parte in un thread separato su
127.0.0.1:8765.--agent-host e --agent-port: cambiano indirizzo e
porta del listener per executor remoti.-v: log a livello DEBUG.
OutboundMessage.buttons è una matrice list[list[dict]].
Ogni dict ha text e data; data
finisce nel callback_data del pulsante Telegram.
Il valore di callback_data identifica semanticamente l'azione e
non dipende dal testo visualizzato. Il daemon gestisce attualmente:
dlg: per le risposte ai passaggi di un dialogo guidato;cap: per approvazioni amministrative o richieste da confermare;promoter: e sched: per proposte e attività pianificate;loc_cancel per annullare una richiesta di posizione;approve: e reject: per richieste precedenti ancora
registrate nell'archivio delle approvazioni.Le etichette dei pulsanti sono localizzate nella lingua dell'utente. Prima di applicare una decisione, il daemon verifica l'identità associata al canale e la proprietà dello stato pendente; possedere o inoltrare un valore di callback non concede alcuna autorizzazione. Le approvazioni e i dialoghi sono monouso e soggetti a scadenza. Per il contratto dell'interazione vedi approvazioni e controllo umano.
pairing
Il daemon non accetta messaggi da chiunque. Prima di elaborare un
InboundMessage risolve il legame fra canale, identificativo del
mittente e utente Metnos. Consulta il registro di pairing e, per
le associazioni multiutente, il registro degli utenti (vedi
pairing):
autonomy_level == "ReadOnly" può
ancora rispondere alle richieste informative riconosciute dal Tutor, ma non
avvia un turno operativo (LEVEL_BLOCKS_RUN in
daemon.py).default_chat_id e nessun altro pairing
esiste su quel canale: bootstrap automatico a
Full (_try_bootstrap). L'opzione
--no-bootstrap disabilita questo comportamento.
I comandi /pair <codice> e /start <token>
sono intercettati prima del controllo di associazione, così possono
essere usati da un mittente non ancora riconosciuto. Il primo consuma un
codice firmato con un livello di autonomia; il secondo consuma il token
monouso e a scadenza emesso dalla gestione utenti. Ogni messaggio di chi
è associato aggiorna anche last_seen tramite
pairing.touch_last_seen.
Il daemon viene distribuito come user unit ed è quindi eseguito con
l'identità dell'utente che ha installato Metnos, non come root.
La sorgente canonica è
install/units/metnos-telegram-daemon.service.tmpl; l'installatore
la rende in ~/.config/systemd/user/metnos-telegram-daemon.service
soltanto quando Telegram è stato configurato.
ExecStart usa l'ambiente virtuale dell'installazione e avvia
runtime.channels.daemon dalla radice del repository.metnos.target e viene avviata dopo il
server HTTP.Restart=on-failure, con attesa di 10 secondi prima di un
nuovo tentativo.journalctl --user -u metnos-telegram-daemon.service -f.
Per mantenere i servizi utente attivi dopo il logout e fra i riavvii, un
amministratore del sistema può abilitare una volta il linger per
l'utente del servizio: sudo loginctl enable-linger "$USER".
La gestione dell'intero gruppo di servizi è descritta in
systemd/README.md.
La chat web comunica con il server HTTP di Metnos sulla porta 8770. Sul
versante agente le rotte principali sono
POST /agent/turn (esegue run_turn, in SSE o JSON),
GET /agent/devices/me, GET /agent/health e
GET /.well-known/metnos.json. Per l'elenco completo vedi
http_api.
Il rapporto fra channel e http_api è
deliberato:
run_turn riceve channel="http" e passa per gli stessi controlli (vaglio, policy) di Telegram.HTTPChannel formale: il modello basato su polling del Channel Protocol non corrisponde al modello richiesta/risposta. POST /agent/turn chiama quindi 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.