Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/jobs/zendesk-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ Two limits bound a message and whichever binds first splits it — 40 components

**That is what lets everything here go over one incoming webhook** — the digest, the positive-review tally and the failure alerts alike. A webhook no application owns may send non-interactive components and nothing else, so adding an interactive one means moving the digest back to a bot token and a channel id. The request needs `?with_components=true` (`digest_webhook_url`): Discord ignores the `components` field on a webhook post without it, and the digest is nothing but components.

## Drafting the known answers

Before posting, the digest drafts a private reply note on each ticket it reports that
support has not answered yet and that a brief support already sent answers; see
[Drafts nobody asked for](zendesk-relay.md#drafts-nobody-asked-for). A 📝 line lists
them under the header. Up to three Claude calls a ticket: placement in the house
answers (skipped when the ticket is already tagged), the choice of brief, and the draft.
Only a ticket placed in one of the groups in `DRAFT_GROUPS` gets past placement.

| | |
| --- | --- |
| Enabled by | `--known-briefs PATH`, which `digest_job.py` sets beside `--state`, and `ZENDESK_HOUSE_ANSWERS` |
| Cache | `known_briefs.json`: briefs read from `tags:claude-sent`, topped up each run, dropped after 180 days. Losing it costs one backfill of those 180 days; a truncated search or a failed comment read rescans from the same point next run |
| Fixed answers | a law-enforcement request gets the standard reply word for word; an abuse report is composed from a standing brief |
| Not drafted | spam and positive reviews, groups outside `DRAFT_GROUPS`, tickets with a live chat open, tickets already tagged `claude-drafted`, `claude-queued` or `claude-sent`, and any ticket with a public reply from support or a private note from anyone but the API user |
| Bounds | 20 minutes of drafting a run, highest priority first; stops after three failures. A failure never fails the digest |
| Dry run | `--dry-run` and a rehearsal compose but write no note and no cache; `--no-discord` drafts nothing |

## Deduplication

The window is 72h against runs a day apart, so consecutive runs overlap. A state file (`--state`) records each reported ticket's `requester_updated_at`, giving three outcomes per ticket:
Expand Down
27 changes: 27 additions & 0 deletions docs/jobs/zendesk-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ which publishes that option **verbatim** and moves the ticket to `pending`. A ba
`claude: reply` sends the only option when there is one, and refuses to guess when
there are several.

## Drafts nobody asked for

The weekday digest writes the same draft note on its own, before it posts, when a
ticket nobody has answered matches an answer support already gave. A known answer is a
`claude: draft` brief that went out as a ticket's first reply with no amendment, from
the same house-answer group. Claude picks the one that fully answers the ticket, or
none, and rejects briefs that name another ticket, promise an action, or carry a fact
that has since expired, like a pending release.

Only the groups in `DRAFT_GROUPS` are drafted: those whose first reply support has
settled on, like reports of illegal content, lost recovery phrases, unrecoverable data
and the common delivery, notification, connection and call failures. A ticket placed
anywhere else, or nowhere, is left to an agent.

A law-enforcement request gets no brief: it gets the standard reply in
`STANDARD_REPLIES` ([auto_draft.py](../../src/session_ops/zendesk/auto_draft.py)), word
for word and untranslated, because what Session tells an authority must not vary.

An abuse report, by the digest's own category and in one of those groups, is composed
from the standing brief in `STANDING_BRIEFS`, in the reporter's language: we can block
the reported Session ID from the communities Session operates, and that is all.

The note says which ticket the brief came from and carries `[claude:auto:<ticket id>]`
instead of a command's markers. Send it with `claude: reply N`. A `claude: draft` after
it starts over from the new brief rather than amending it: amending keeps wording an
agent approved, and nobody approved this one.

## The commands

| Command | What it does | Touches the customer |
Expand Down
33 changes: 33 additions & 0 deletions src/session_ops/zendesk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,39 @@ def hydrate_requester_activity(session, subdomain, tickets):
return hydrated


MESSAGING_CHANNEL_PREFIX = "sunshine_conversations"
CHAT_EVENTS = ("ChatStartedEvent", "ChatEndedEvent")


def is_messaging(ticket):
return ((ticket.get("via") or {}).get("channel") or "").startswith(MESSAGING_CHANNEL_PREFIX)


def has_open_chat(session, subdomain, ticket_id):
"""Whether the ticket's newest live chat has not ended; None when it cannot be read.

Zendesk writes a chat into the ticket as a comment only when it ends, so until then
the customer's newest messages are on no comment, and only the assignee can open
the chat. One that starts while the ticket has an assignee is never ended for it.
"""
url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}/audits.json"
try:
resp = session.request("GET", url, attempts=2,
params={"sort_order": "desc", "per_page": 100})
if resp.status_code >= 400:
print(f"Note: audits for #{ticket_id} returned {resp.status_code}.")
return None
audits = resp.json().get("audits", [])
except (requests.RequestException, ValueError) as exc:
print(f"Note: could not read the audits for #{ticket_id} ({exc}).")
return None
for audit in audits:
for event in reversed(audit.get("events") or []):
if event.get("type") in CHAT_EVENTS:
return event["type"] == "ChatStartedEvent"
return False


STAR_SUBJECT = re.compile(r"^\s*([★☆]{1,10})")


Expand Down
Loading
Loading