The push half accepts an @channelusername and the intake half cannot: an
inbound update names its chat by number, so an @-name matches nothing. The
check lived in NewPoller, which wireTelegramIntake logs and returns from, so a
box configured that way booted clean with a dead intake half and a working push
half. Nothing looked broken from the chat.
ValidateIntakeChatID moves the rule where config validation can reach it, the
same shape validateNetScan uses. It is stricter than the old prefix test: any
non-digit is refused, not just a leading @. An empty token or chat id still
means telegram is not wired, because an unset ${TELEGRAM_*} expands to empty
and that must not fail a box with no bot.
deploy/mavend.json turns intake on. The chat id on this box is numeric.
The onCallback comment claimed every path answers the callback. The fromOwner
early return does not, and silence toward a stranger is correct, so the comment
was what was wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects my own review found.
The push half accepts @channelusername as a destination. The intake half
cannot: an inbound update names its chat by numeric id, so that config would
read the chat, match nothing, and answer none of it. Refused at NewPoller,
which turns a dead reach into a line in the log.
And getUpdates returns at most 100 updates per call, so one call was not the
backlog. The skip loops, bounded at ten rounds rather than until empty, so
an instance that keeps handing back a full batch cannot spin.
The cases that matter: the turn runs with the chat as its dialogue id, the
reply carries the gesture, a turn nothing persisted carries no buttons, a
stranger gets no answer at all, the first tap writes nothing, and a write
that failed says so on the button instead of going quiet.
An httptest server that hands out one batch of updates per getUpdates call
and records everything else, plus a recorder for what the poller asked the
daemon to do.
Long-poll getUpdates rather than a webhook: the box takes no inbound
connections and reaches telegram through a relay, so the direction has to
stay outbound. A failed poll waits and retries, because the relay going
down is the normal cause and it comes back on its own.
The backlog is discarded on start. Telegram holds undelivered updates for
24 hours, so a daemon that was down overnight would otherwise answer every
question in order, and a reminder set from an eight-hour-old message lands
at the wrong time. Missing it is the safe direction.
ChatID is the only accepted sender and anything else is dropped without a
reply, because a reply confirms the bot exists and whose it is. Chat ids are
not guessable but they are not secret either, so that is the whole
authorisation and it is an allowlist of one.
getUpdates, sendMessage, answerCallbackQuery and editMessageReplyMarkup,
plus the inbound shapes cut to what the poller reads. Every error goes
through the sink's redaction: the token is in the URL path because telegram
accepts it nowhere else, and net/http prints that URL on a transport
failure.
Only ok=true is a success, the same rule the push half already applies. A
relay that is up but cannot reach api.telegram.org answers 200 with an HTML
page of its own, and reading that as a batch of updates would be silent.
A chat id arrives as a number for a user and a string for a channel, so it
is held as json.Number and never converted.
Config gains an intake flag, off by default, and sendMessageReq gains the
inline keyboard the intake half hangs under a reply. The gesture itself is
the web's, ported: one button says the turn was wrong, and it opens the
seven intents rather than writing the negative straight away, because the
target is worth much more and he must still be able to decline naming one.
Button data comes off the wire, so parseCallback refuses an id it cannot
parse and a target that is not one of the seven. A label nothing can score
is worse than no label.
The sink parsed the response, and when the body did not unmarshal it fell
through to the status check and returned nil on any 2xx. This box reaches
telegram through a relay, and a relay that is up but cannot reach
api.telegram.org answers 200 with a page of its own. That read as delivered:
the dispatcher wrote a 'sent' outbox row and MarkSent restarted the repeat
clock, so a sev4 alarm nobody received went quiet for a full interval.
Only ok=true is a send now. The response cap moves from 4096 to 64KiB, because
a truncated body no longer parses and would read as a failure, and error lines
carry a 200-byte snippet instead of the relay's whole page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
net/http wraps every transport failure in *url.Error, whose Error() prints
the request URL. Telegram accepts the bot token nowhere but the URL path, so
a send failure wrote the live token into the daemon log. On 2026-08-01
homesrv could not reach api.telegram.org and did that once a minute for as
long as the network stayed down. The token lives in deploy/telegram.env to
stay out of the repo; putting it in `docker compose logs` undoes that.
Both error sites now go through redact. The structural branch rewrites
url.Error.URL and keeps the type, so errors.As still matches; anything else
falls back to scrubbing the rendered message. No minimum-token-length guard:
a one-character token would shred the message, but that beats leaking it.
DESIGN.md still said the classifier cascade was the path that runs today
with llmrouter wired nil, and gave the resident checkpoint as Qwen3.5-0.8B.
Both stopped being true on 2026-07-31.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
The away sinks fell back to the whole Body when Summary was empty. ntfy and
telegram leave the box, and the 0.8B phraser drops fields regularly, so that
fallback could push full detail off the machine.
The dispatcher already strips detail from away sendables. This exports that
one rule as delivery.AwayMessage and has both sinks use it, so a sink can't
leak the body on its own either: empty Summary means a generic line plus the
rule name, never the body.
The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the
old, wrong behaviour, so they are rewritten to assert the generic line.
TestSendRejectsEmptyMessage is likewise replaced: an away message can no
longer be empty, so the sink has nothing left to reject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go
Co-Authored-By: opencode <opencode@anthropic.com>