channel — adapter dei canali
Un Channel è l'adapter tra un protocollo di messaging esterno e il
gateway. Fa due mestieri: ingest (traduce input esterno in
ingest(channel, sender, message)) e render
(traduce la risposta di myclaw nelle convenzioni del protocollo). Il
requisito non opzionale di fase 1 è status visibility:
nessun utente aspetta muto.
Channel.pairing.html).ChannelUn Channel è un oggetto asincrono con due responsabilità: pump input verso il gateway, push output verso il mondo. Espone capability flag che il gateway usa per dimensionare l'esperienza (es. "supporta bottoni inline?" → approvazioni con UI ricca vs testo puro).
from typing import Protocol, Literal
from dataclasses import dataclass
from uuid import UUID
@dataclass
class ChannelCapabilities:
supports_inline_buttons: bool # Telegram, Signal: sì. CLI: no.
supports_streaming: bool # CLI WS: sì. Telegram: edit message.
supports_typing_indicator: bool # Telegram: sì. CLI: simulato con spinner.
supports_markdown: Literal["none", "basic", "full"]
supports_voice: bool # futuro
max_message_length: int # Telegram: 4096
max_messages_per_minute: int # rate-limit outbound
class Channel(Protocol):
name: str # "cli" | "telegram" | ...
capabilities: ChannelCapabilities
async def start(self, gateway: Gateway) -> None:
"""Avvia pump (long-poll, listener, etc)."""
...
async def shutdown(self) -> None: ...
async def send(
self,
sender: str,
content: OutboundMessage,
) -> str:
"""
Invia un messaggio al sender. Ritorna un message_id opaco che
permette edit successivi (per status updates e approval buttons).
"""
...
async def edit(
self,
sender: str,
message_id: str,
content: OutboundMessage,
) -> None: ...
async def show_typing(self, sender: str, seconds: float = 3.0) -> None:
"""Mostra indicatore di attività. No-op se capability lo vieta."""
...
async def present_approval(
self,
sender: str,
request: ApprovalRequest,
) -> str:
"""Renderizza una ApprovalRequest secondo le capability del canale."""
...
def default_approval_timeout(self) -> timedelta:
"""
Timeout di default per una richiesta di approvazione su questo canale.
CLI: 30s · Telegram DM: 2m · canale di casa: 5m · voce: 15s.
Fonte canonica della tabella: approval_ux.html §8.
Il Gateway legge da qui, non hardcoda valori.
"""
...
OutboundMessage@dataclass
class OutboundMessage:
text: str
format: Literal["plain", "markdown"] = "plain"
buttons: list[Button] | None = None # None se no inline
replace_previous: bool = False # vedi nota sotto
replace_target: str | None = None # message_id da rimpiazzare
attachment: Attachment | None = None # file / image, futuro
@dataclass
class Button:
label: str
callback_data: str
style: Literal["primary", "secondary", "danger"] = "secondary"
disabled_until_ms: int = 0 # pausa lettura approval_ux §5
replace_previous. Quando True, il Gateway
passa il message_id dell'ultimo messaggio inviato (proveniente da send()
precedente) in replace_target. Il canale sceglie l'implementazione più
adatta al suo protocollo:
editMessageText se l'ID è ancora editabile, altrimenti send + delete vecchio.replace_line con il nuovo testo; l'UI cancella la riga e riscrive.replace_target è None con replace_previous=True: il canale logga un warning e fa fallback a send() normale.Il canale CLI è quello che Roberto userà dal terminale del PC. Massima interattività, supporto streaming, latenza sub-5ms.
myclaw chat (in src/myclaw/cli/chat.py)./tmp/myclaw.sock (Unix domain socket, chmod 600, uid check)./ws/internal.thought_start, thought_partial_text, tool_call_start, tool_call_result, final_response.$ myclaw chat
myclaw · supervised · session s_4a7b...
> riassumi gli errori di stamattina
◐ sto cercando il log di systemd...
tool: shell_run(journalctl --since "today" --priority err)
✓ letto (42 righe, 1.3KB)
◐ sto riassumendo...
Stamattina ci sono stati 3 errori:
- alle 08:14, wpa_supplicant ha perso l'associazione (ri-collegato alle 08:14:30)
- alle 09:22, docker ha fallito a pullare un'immagine (network timeout)
- alle 11:04, smartd ha rilevato un contatore SMART anomalo su /dev/sda
>
Lo spinner (◐◓◑◒) gira durante ogni passo. Il nome del tool è
sempre esposto (§5). I tool_result sono riassunti in una riga.
Il testo della risposta finale arriva in streaming token-per-token (SSE da
agent_runtime, inoltrato via WS). Il CLI stampa on-the-fly per
l'effetto "l'agente sta parlando". Latenza prima-lettera < 1s su frontier
model.
config/secrets.env come MYCLAW_TELEGRAM_TOKEN./start, /help, /undo, /bye.# src/myclaw/channels/telegram.py (schematico)
async def pump(self, gateway):
offset = 0
while not self._stopped:
updates = await self._api("getUpdates", offset=offset, timeout=25)
for upd in updates:
offset = upd["update_id"] + 1
if "message" in upd:
await self._handle_message(gateway, upd["message"])
elif "callback_query" in upd:
await self._handle_callback(gateway, upd["callback_query"])
Il canale riceve present_approval(sender, request) dal gateway e
compone un messaggio con inline buttons secondo le specifiche di
approval_ux §4:
# Messaggio:
Vuoi che scarichi?
📄 rapporto_marzo.pdf
da drive.google.com
→ ~/downloads/ (~2.4 MB)
*reversibile · classe: fs_write:~/downloads/**
# Inline keyboard:
[✅ attendi 3s...] (disabled, timer sostituisce progressivamente)
[❌ No]
[✓ approva classe per 10 min]
Il "disabled per 3s" è simulato editando il messaggio dopo 3s con il pulsante abilitato (Telegram non ha veri "disabled buttons", si fa con l'edit). Contatore visibile all'utente: "attendi 2s..." → "attendi 1s..." → "Approva ✅".
Telegram accetta max ~30 messaggi/secondo. Il canale fa il throttling interno con una token-bucket queue. Per un singolo utente in casa questo non sarà mai un problema, ma il codice è ready per aggiungere famiglia.
Il gateway emette eventi del loop in tempo reale. Ogni canale ha la responsabilità di tradurli in feedback visibile all'utente. La tabella seguente mostra, per tipo di evento, cosa fa ciascun canale. Questa è la reificazione concreta della critica #4.
| Evento runtime | CLI | Telegram |
|---|---|---|
| thought_start | Spinner ◐ + "penso..." |
Typing indicator + messaggio "penso..." (edit in-place) |
| tool_call_start(name, args_summary) | "◐ <name>(<summary>)..." |
Edit messaggio: "🔧 name · summary..." |
| tool_call_result(outcome_summary) | "✓ <outcome>" (o ✗ se errore) |
Edit: "✅ outcome" (o ❌) |
| final_response (streaming) | Stampa token-by-token | Edit progressivo del messaggio ogni ~500ms |
| approval_required(req) | Blocca prompt, mostra richiesta strutturata, attende input [y/N/b/never] |
Nuovo messaggio con inline buttons |
| policy_denied(reason) | "⛔ reason" | "⛔ reason" |
| budget_hit | "⚠ budget giornaliero esaurito" | "⚠ budget giornaliero esaurito" |
| Elemento | CLI | Telegram |
|---|---|---|
| Enfasi | *testo* (ANSI bold) | Markdown V2: *testo* |
| Monospazio | ANSI gray background | Backtick: `testo` |
| Link | URL nudo | Markdown V2 [label](url) |
| Codice block | Indent + ANSI color | ```python |
| Emoji | Unicode (terminale supportato) | Unicode, volutamente sobrio |
| Separatori | --- → riga orizzontale | Newline doppia |
Il runtime emette testo in un formato canonico interno (chiamato MyclawMD: sottoinsieme markdown + marker speciali per status). Il canale traduce.
Oltre ai Channel e tipi già definiti sopra:
class ChannelRegistry(Protocol):
"""Registra le implementazioni Channel attive al boot del gateway."""
def register(self, channel: Channel) -> None: ...
def get(self, name: str) -> Channel: ...
def list_all(self) -> list[Channel]: ...
class ChannelEvent(Protocol):
"""Evento che il canale riceve dal gateway per aggiornare l'utente."""
event_type: str
trace_id: UUID
sender: str
payload: dict
# Tipi di evento emessi dal runtime verso i canali:
# - thought_start
# - thought_stream(partial_text)
# - tool_call_start(name, args_summary)
# - tool_call_result(outcome_summary, error=None)
# - approval_required(request_id, request)
# - approval_resolved(request_id, granted, via)
# - final_response_stream(partial_text)
# - final_response_complete(text)
# - policy_denied(reason)
# - budget_hit(current, hard_cap)
# - trace_closed(outcome, cost, wall_ms)
| Eccezione | Quando |
|---|---|
ChannelTransientError | Errore temporaneo (timeout, 5xx). Retry con backoff. |
ChannelPermanentError | Token scaduto, bot kicked, sender blocked. Notifica admin. |
MessageTooLongError | Il testo supera max_message_length. Il runtime dovrebbe dividere. |
RateLimitError | Rate-limit hit. Backoff automatico. |
| Alternativa | Perché scartata |
|---|---|
| pyrogram / aiogram per Telegram | Belle libreria ma aggiungono deps grandi. Il sottoinsieme di Bot API che ci serve (5 chiamate) si implementa in ~200 righe. Ridurre dipendenze paga in manutenzione. |
| Webhook pubblico Telegram | Già escluso in gateway §5: richiede TLS pubblico, reverse-proxy, DDNS. Long-poll è equivalente con 1/1000 del setup. |
| TUI ricca (Textual, prompt-toolkit) | Bello ma fuori scope. Un loop readline + ANSI codes è sufficiente. Un vero TUI diventerebbe una UI secondaria da mantenere. |
| Un solo Channel monolitico con "mode" dinamico |
Invece di CLI/Telegram come classi separate, un Channel unico con
mode="cli" o mode="telegram". Rifiutato: le
astrazioni comuni sono lievi, le differenze sono tante. Separazione per
impl è più chiara.
|
| Signal/WhatsApp come fase 1 | Signal richiede signal-cli o API fragili. WhatsApp richiede Business API complessa o soluzioni instabili. Rimandati a fase 4+ quando servono. |
| Invariante | Test |
|---|---|
| capabilities esposte correttamente | Instanziare ogni Channel e verificare che capabilities.supports_* matchi il comportamento reale. |
| CLI respinge connessioni da altri uid | Mock client con uid diverso da roberto → connessione rifiutata, log warning. |
| Telegram non reagisce a privacy-mode-disabled | Mock update "gruppo, no mention" → ignorato silenziosamente se privacy_mode=on. |
| Status visibility: ogni tool_call_start è reso | Fixture con 3 tool call in sequence → canale deve mostrare 3 aggiornamenti. Assente = fail. |
| Pausa lettura rispettata nei bottoni Telegram | Bottone "Approva" disabled 3s (simulato via edit). Click durante 3s → 400 dal gateway (mismatch). |
| Streaming final_response | Client CLI collegato al WS riceve almeno 3 eventi final_response_stream per risposta > 500 token. |
| Rate limit outbound | Forzare 50 send in 1s → token bucket rallenta a 30/s, nessuna 429 da Telegram. |
| Split messaggio lungo | Risposta da 6000 caratteri per Telegram → canale spezza in 2 messaggi contigui, nessuno > 4096. |
| ChannelPermanentError propagato | Bot kicked → canale solleva, gateway logga, admin notificato via CLI status. |
| Shutdown pulito | myclaw-gateway restart → tutti i canali chiudono i loro pump entro 5s. |
| Riferimento | Cosa abbiamo preso |
|---|---|
Telegram Bot API (getUpdates, inline keyboards) | La pipeline long-poll (§4) e il rendering dell'approvazione. |
| Giudizio di fase 0 — critica #4 status visibility | Il mandato "ogni canale deve mostrare lo stato". Reificato in §5. |
| Nielsen, 10 Usability Heuristics (in particolare "visibility of system status") | Radice teorica della §5. |
| ANSI escape codes (ECMA-48) | Per il rendering CLI. |
approval_ux.html §4 e §5 | Le regole di batching e pausa lettura che il canale deve applicare. |
Tool e la base set: fs, shell, web_fetch, supra adapters. Cosa myclaw sa fare concretamente.
myclaw — channel microprogettazione v1.0 — 2026-04-21
Secondo dei 4 classici. Prossimo: tool.html.