Skip to content

feat: reply to Zendesk tickets from the digest's Comment button - #46

Merged
Bilb merged 11 commits into
mainfrom
feat/reply-from-discord
Aug 28, 2026
Merged

feat: reply to Zendesk tickets from the digest's Comment button#46
Bilb merged 11 commits into
mainfrom
feat/reply-from-discord

Conversation

@Bilb

@Bilb Bilb commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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 to reply.py.
  • reply.py — fetches the ticket, detects the requester's language, translates, previews, and writes the comment and note.
  • The digest moves to Components V2: a Section per ticket carrying its own button, since components attach to a message rather than an embed.
  • Both Claude calls — the digest's classification and the reply translation — now go through the local claude --print CLI, 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, in triage.py: #45's collapsed abuse-report line and this branch's build_ticket_section were 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_button pins it.

346 tests pass.

Bilb added 6 commits August 21, 2026 14:36
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.
Comment thread deploy/zendesk-digest.timer
Comment thread zendesk_triage/reply.py
Comment thread deploy/README.md Outdated
Comment thread zendesk_triage/relay.py Outdated
Bilb added 5 commits August 25, 2026 17:25
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.
@Bilb
Bilb merged commit b6690b5 into main Aug 28, 2026
@Bilb
Bilb deleted the feat/reply-from-discord branch August 28, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants