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
4 changes: 4 additions & 0 deletions docs/jobs/zendesk-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ 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.

## Live chats stuck open

Zendesk writes a Twitter DM chat onto the ticket only when the chat ends, and it never ends a chat that started while the ticket had an assignee. The auto-assign trigger gives every ticket one, so a customer writing back on a solved DM ticket is invisible to everyone but the assignee. Each run sweeps the unsolved DM tickets for an open chat and unassigns them; the chat then ends about 10 minutes later and the message lands on the ticket. The digest lists them under 💬 and records none of them, so each is reported again with its newest message. A ticket Zendesk refused to unassign is listed apart, for an admin. `--dry-run`, `--no-discord` and a rehearsal list them without unassigning.

## Drafting the known answers

Before posting, the digest drafts a private reply note on each ticket it reports that
Expand Down
15 changes: 15 additions & 0 deletions src/session_ops/zendesk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ def has_open_chat(session, subdomain, ticket_id):
return False


def unassign(session, subdomain, ticket_id):
"""Clear the ticket's assignee, which is what lets Zendesk end its live chat.
Returns whether Zendesk took it."""
url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json"
try:
resp = session.request("PUT", url, json={"ticket": {"assignee_id": None}})
except requests.RequestException as exc:
print(f"Note: could not unassign #{ticket_id} ({exc}).")
return False
if resp.status_code >= 400:
print(f"Note: unassigning #{ticket_id} returned {resp.status_code}.")
return False
return True


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


Expand Down
49 changes: 39 additions & 10 deletions src/session_ops/zendesk/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
from session_ops.shared.env import get_env, rehearsing
from session_ops.shared.text import squash
from session_ops.zendesk.api import (REVIEW_CHANNEL, SEARCH_RESULT_LIMIT, fetch_comments,
fetch_tickets, has_open_chat,
fetch_tickets, has_open_chat, unassign,
hydrate_requester_activity, is_store_review,
review_platform, review_stars, ticket_url,
zendesk_session)
Expand Down Expand Up @@ -518,6 +518,16 @@ def find_open_chats(session, subdomain):
return {t["id"] for t in tickets if has_open_chat(session, subdomain, t["id"])}


def release_open_chats(session, subdomain, ticket_ids):
"""Unassign each ticket so Zendesk ends its chat and writes the customer's newest
message onto it, about 10 minutes later. Returns the ids Zendesk accepted.

Zendesk never ends a chat that started while the ticket had an assignee, and the
auto-assign trigger gives every ticket one.
"""
return {tid for tid in ticket_ids if unassign(session, subdomain, tid)}


# ---- The classification request ---------------------------------------------


Expand Down Expand Up @@ -838,21 +848,34 @@ def build_non_english_line(findings, subdomain, updated_ids):
f"{', '.join(links)}", set())


def build_open_chat_line(ticket_ids, subdomain):
def build_open_chat_line(ticket_ids, subdomain, released=()):
"""The tickets whose live chat is still open, so nobody but the assignee can read
the customer's newest message. None when there are none.

💬 **2** live chats still open — newest message not on the ticket until an
admin unassigns it: #28835, #26946
💬 **2** live chats still open: unassigned, so the newest message reaches the
ticket in about 10 minutes: #28835, #26946

Covers no ticket: one stays listed every run until its chat ends.
A ticket the run could not unassign is listed apart, for an admin. Covers no
ticket: the conversation it was classified from is missing its newest message.
"""
if not ticket_ids:
return None
links = ", ".join(f"[#{tid}]({ticket_url(subdomain, tid)})" for tid in sorted(ticket_ids))
plural = "" if len(ticket_ids) == 1 else "s"
return (f"{OPEN_CHAT_MARKER} **{len(ticket_ids)}** live chat{plural} still open — "
f"newest message not on the ticket until an admin unassigns it: {links}", set())

def part(ids, outcome):
links = ", ".join(f"[#{tid}]({ticket_url(subdomain, tid)})" for tid in sorted(ids))
plural = "" if len(ids) == 1 else "s"
return f"{OPEN_CHAT_MARKER} **{len(ids)}** live chat{plural} still open: {outcome}: {links}"

released = set(released) & set(ticket_ids)
stuck = set(ticket_ids) - released
parts = []
if released:
parts.append(part(released, "unassigned, so the newest message reaches the ticket "
"in about 10 minutes"))
if stuck:
parts.append(part(stuck, "newest message not on the ticket until an admin "
"unassigns it"))
return "\n".join(parts), set()


def build_drafted_line(ticket_ids, subdomain):
Expand Down Expand Up @@ -992,7 +1015,8 @@ def build_messages(findings, subdomain, stats=None, updated_ids=None):
if omitted:
header += (f"\nShowing the top **{len(shown)}** of "
f"**{len(shown) + len(omitted)}** worth looking into.")
open_chats = build_open_chat_line((stats or {}).get("open_chats"), subdomain)
open_chats = build_open_chat_line((stats or {}).get("open_chats"), subdomain,
(stats or {}).get("released_chats") or ())
non_english = build_non_english_line(findings, subdomain, updated_ids)
drafted = build_drafted_line((stats or {}).get("drafted"), subdomain)
# First, under the header: the run's to-do lists, for an admin and for the `claude:` notes.
Expand Down Expand Up @@ -1126,6 +1150,11 @@ def main(argv=None):
stats["open_chats"] = find_open_chats(zd, subdomain)
if stats["open_chats"]:
print(f"{len(stats['open_chats'])} ticket(s) have a live chat still open.")
# A counts-only run must not write to tickets.
if not (args.dry_run or args.no_discord or rehearsing()):
stats["released_chats"] = release_open_chats(zd, subdomain,
stats["open_chats"])
print(f"Unassigned {len(stats['released_chats'])} of them.")

# Drop positive store reviews before anything expensive: they were 59% of all
# tickets in the sample and never actionable.
Expand Down
22 changes: 22 additions & 0 deletions tests/zendesk/test_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,8 +794,30 @@ def test_it_lists_the_swept_tickets_first(self):
messages, _ = triage.build_messages(findings, "acme", {"open_chats": {28835, 26946}})
lines = [l for l in digest_text(messages).splitlines() if "[#" in l]
self.assertIn("**2** live chats still open", lines[0])
self.assertIn("until an admin unassigns it", lines[0])
self.assertLess(lines[0].index("[#26946]"), lines[0].index("[#28835]"))

def test_released_tickets_are_listed_apart_from_stuck_ones(self):
stats = {"open_chats": {1, 2, 3}, "released_chats": {1, 3}}
messages, _ = triage.build_messages([finding(9)], "acme", stats)
lines = [l for l in digest_text(messages).splitlines() if "live chat" in l]
self.assertEqual(len(lines), 2)
self.assertIn("**2** live chats still open: unassigned", lines[0])
self.assertIn("[#1]", lines[0])
self.assertIn("[#3]", lines[0])
self.assertIn("**1** live chat still open: newest message not on the ticket", lines[1])
self.assertIn("[#2]", lines[1])

def test_release_unassigns_each_and_keeps_what_zendesk_took(self):
session = FakeSession([FakeResponse({}), FakeResponse({}, status_code=403)])
with contextlib.redirect_stdout(io.StringIO()):
released = triage.release_open_chats(session, "acme", [4, 5])
self.assertEqual(released, {4})
method, url, kwargs = session.calls[0]
self.assertEqual(method, "PUT")
self.assertTrue(url.endswith("/tickets/4.json"))
self.assertEqual(kwargs["json"], {"ticket": {"assignee_id": None}})

def test_it_covers_no_ticket(self):
_, coverage = triage.build_messages([finding(1)], "acme", {"open_chats": {9}})
self.assertNotIn(9, set().union(*coverage))
Expand Down
Loading