feat: reply to Zendesk tickets from the digest's Comment button - #46
Merged
Conversation
The collapsed abuse-report line from #45 renders as a plain Text Display with no accessory. Every other entry becomes a Section carrying a Comment button keyed to its ticket, and the collapsed line stands for all of them at once — wrapping it the same way gave it a button whose custom_id was an arbitrary member of the set. A group nobody can act on is also the last thing that should offer a reply. #45's two rendering tests read message["content"], which Components V2 no longer has; ported to the text_displays helper and the per-message component budget.
Both Claude calls shell out to `claude --print` now, and the CLI reads its login from $HOME. ProtectHome=yes made /home inaccessible and ProtectSystem=strict left the relay with nothing writable at all, so the translation would have failed with a bare non-zero exit from a binary that looked correctly installed. ProtectHome=tmpfs masks /home with an empty one and BindPaths mounts only this account's back in, so the other homes on the box stay hidden. The account's home moves off /opt/zendesk, which the relay mounts read-only. Also documents re-copying the units on update — they are read from /etc/systemd/system, not the checkout.
mpretty-cyro
requested changes
Aug 24, 2026
Review fixes on the reply-from-Discord flow.
reply.py reports its own refusals and then exits, so a crash, a non-zero exit
and the timeout kill each left the agent's message on "⏳ Sending…" — which reads
as still working for a write that may already have emailed the customer. The
relay now edits that message itself, using the interaction token it already
holds. Worded "it may have gone out", because a run killed after the private
note may have got as far as the reply and only the ticket knows.
`--tools ""` was documented as keeping the CLI away from a project's CLAUDE.md,
skills and settings. It does not: it removes the tools. Without
`--setting-sources ""` the user's and the project's settings — and the hooks in
them — join every classification and every translation. Nothing in the repo
carries either today, which is the only reason it was invisible.
Also:
* A timestamp from this host's future is accepted within a minute. Refusing
them made the endpoint's health a matter of NTP: a host a second behind
Discord refused every interaction, the endpoint-registering PING included.
* `max_tokens` is named as itself again. There is no --max-tokens to raise, so
a batch too large to answer came back as JSON that stops mid-object and was
reported as a syntax error. MAX_OUTPUT_TOKENS, unread since the move off the
SDK, is gone.
* `--local` needs `--dry-run`. Alone it only redirected what went back to
Discord and still posted a public comment. Its comment cited a workflow this
branch deletes.
* The Comment labels come off the Components V2 character budget, which they
were within thirty characters of overspending.
* requirements-dev.txt: the suite could not be run from a clean install of
requirements.txt, because starlette's TestClient ships no HTTP client.
* The collapsed abuse-report line is found by identity rather than by comparing
id sets, which worked only because a collapsed ticket never also appears as
its own line.
* Patched lived twice, in two spellings; it joins the other shared helpers.
376 tests pass.
The dialog took the requester to be whoever wrote the first comment, because that was free — the comments call was the only one it made. reply.py reads requester_id, which is a field on the ticket and nothing on its comments, so on every ticket somebody other than the requester opened the two disagreed: an agent taking a phone call, or the review importer, is the author of comment 0. The dialog then showed that agent's English under "What they wrote" while the translation was chosen from the real requester's words — so on a German ticket opened by an agent it read as English and went out in German, with nothing on screen to say so. The existing test fixed author_id on comment 0, which made the two definitions agree by construction. Both calls now run concurrently, so they cost about what one cost and the three-second modal budget is unaffected. Reading the ticket also means a closed one is refused before the box opens rather than after a whole reply has been typed into it — Zendesk takes no comment on a closed ticket, and closing cannot be undone. Either half can fail alone. Without the ticket there is nobody to filter on, so every comment is shown under a heading that does not claim whose words they are; an unreachable Zendesk is never mistaken for a closed ticket. ticket_context is now given what was fetched rather than fetching it, which is what lets the filtering be asserted without a session in the way. 385 tests pass.
reply.py posts the private note first, because it carries the idempotency marker; the outage notes described the opposite.
The CLI authenticates as whatever `claude` is logged in as, but an API key, auth token or base URL in the environment takes precedence over that login. A host that once ran the API backend still carries the key in its EnvironmentFile, where it is now dead config: on angus it reached the child and the CLI refused the translation outright. Drop the four for the child only, so the run depends on the login alone.
mpretty-cyro
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Comment button to every ticket in the Discord digest. Pressing it opens a compose dialog; the reply is translated into the requester's language, previewed with a back-translation for the agent to check, and posted to Zendesk as a public comment with a private note recording the English original and who sent it.
relay.py— the Discord interactions endpoint (FastAPI, Ed25519 signature verification), which hands each interaction toreply.py.reply.py— fetches the ticket, detects the requester's language, translates, previews, and writes the comment and note.claude --printCLI, so the deployment holds no Claude API key at all.deploy/— systemd units, timer, nginx config and a failure alerter, replacing the GitHub Actions workflows.Merge note
Merges current
main(#43, #44, #45). One conflict, intriage.py: #45's collapsed abuse-report line and this branch'sbuild_ticket_sectionwere added at the same place. Both are kept.The non-obvious part is that the auto-merge was wrong in a way neither side's tests could catch. Every entry became a Section with a Comment button keyed to
next(iter(ids)); the collapsed line carries the ids of all the abuse reports, so it got a button pointing at an arbitrary member of that set. It now renders as a plain Text Display with no accessory — there is no single ticket to reply to, and a group nobody can act on should not offer a reply.test_the_collapsed_line_carries_no_comment_buttonpins it.346 tests pass.